Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions macos/LCTMac/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import SwiftUI
import AppKit
import Combine

/// The main window's SwiftUI scene. SwiftUI also uses the id as the
/// window's `NSWindow.identifier`.
enum MainWindow {
static let id = "main"
}

/// A start/stop request from the Window menu, the status bar item, or the
/// global hotkey, decoded from a `.toggleCapture` notification.
enum CaptureCommand: Equatable {
case start
case stop
case toggle

/// `object: true` starts, `object: false` stops, anything else toggles
/// (start if idle, stop if capturing).
init(notificationObject object: Any?) {
switch object as? Bool {
case true?: self = .start
case false?: self = .stop
case nil: self = .toggle
}
}
}

/// App-lifetime owner of the transcription view model and the subtitle
/// overlay, and the one observer of the app-level commands: start/stop,
/// pause, overlay, session export, and settings changes from the menu bar,
/// the status bar item, the global hotkeys, and the Settings window.
///
/// It lives on the app delegate rather than in a view because the app keeps
/// running in the menu bar after the main window is closed: those commands,
/// and any capture session in progress, must keep working without the
/// window. MainView only displays the view model.
@MainActor
final class AppCoordinator {
let overlayController = OverlayWindowController()

private let isSetupComplete: @MainActor () -> Bool
private let isMainWindowVisible: @MainActor () -> Bool
private let makeViewModel: @MainActor () -> TranscriptionViewModel
private var createdViewModel: TranscriptionViewModel?
private var mainWindowOpener: (@MainActor () -> Void)?
private var cancellables = Set<AnyCancellable>()

init(notificationCenter: NotificationCenter = .default,
isSetupComplete: @escaping @MainActor () -> Bool = { AppSettings.hasCompletedSetup },
isMainWindowVisible: @escaping @MainActor () -> Bool = AppCoordinator.mainWindowIsVisible,
makeViewModel: @escaping @MainActor () -> TranscriptionViewModel = AppCoordinator.makeAppViewModel) {
self.isSetupComplete = isSetupComplete
self.isMainWindowVisible = isMainWindowVisible
self.makeViewModel = makeViewModel
observeCommands(on: notificationCenter)
}

/// The app's single view model, created on first use and kept for the
/// rest of the run, so closing and reopening the main window keeps the
/// session. Only touched after onboarding, so it loads the settings
/// onboarding saved.
var viewModel: TranscriptionViewModel {
if let createdViewModel { return createdViewModel }
let viewModel = makeViewModel()
createdViewModel = viewModel
return viewModel
}

// MARK: - Main window

/// Register how to open the main window: SwiftUI's `openWindow` action,
/// the only public way to reopen a closed SwiftUI window. Set by
/// `MainWindowCommands`.
func setMainWindowOpener(_ opener: @escaping @MainActor () -> Void) {
mainWindowOpener = opener
}

/// Activate the app and bring the main window to the front, reopening it
/// if it was closed or restoring it if it was minimized.
func showMainWindow() {
NSApp?.activate(ignoringOtherApps: true)
if let mainWindowOpener {
mainWindowOpener()
} else {
NSApp?.windows.first { $0.identifier?.rawValue == MainWindow.id }?.makeKeyAndOrderFront(nil)
}
}

// MARK: - Commands

/// Deliver a start/stop request. If the main window is closed or
/// minimized it comes back first, so the user sees the capture state and
/// any error notice; when it is already on screen, nothing steals focus
/// (the global hotkey works from other apps).
func perform(_ command: CaptureCommand) async {
let windowVisible = isMainWindowVisible()
appLog("[AppCoordinator] Capture command \(command) — main window \(windowVisible ? "visible" : "not visible, showing it")")
if !windowVisible {
showMainWindow()
}

// The window shows onboarding until setup completes; there is no
// capture session to control yet.
guard isSetupComplete() else {
appLog("[AppCoordinator] ⏭ Capture command ignored — setup not completed")
return
}

switch command {
case .start:
await viewModel.start()
case .stop:
await viewModel.stop()
case .toggle:
await viewModel.toggleCapture()
}
}

private func togglePause() {
guard isSetupComplete() else { return }
viewModel.togglePause()
}

private func toggleOverlay() {
guard isSetupComplete() else { return }
overlayController.toggle(with: viewModel)
}

/// File ▸ Export. The session outlives the main window, so it can still
/// be exported after the window was closed; the window comes back first
/// so the export's result notice is visible.
func exportSession(_ format: SessionExporter.Format) {
guard isSetupComplete() else { return }
if !isMainWindowVisible() {
showMainWindow()
}
viewModel.exportSession(format)
}

/// The ⌘, Settings window saved new settings — apply them to the running
/// app. A view model created later loads them itself. updateSettings
/// never re-posts this notification, so there is no loop.
private func applySettings(_ newSettings: AppSettings) {
guard let createdViewModel, newSettings != createdViewModel.settings else { return }
createdViewModel.updateSettings(newSettings)
}

private func observeCommands(on notificationCenter: NotificationCenter) {
notificationCenter.publisher(for: .toggleCapture)
.map { CaptureCommand(notificationObject: $0.object) }
.receive(on: DispatchQueue.main)
.sink { [weak self] command in
Task { await self?.perform(command) }
}
.store(in: &cancellables)

notificationCenter.publisher(for: .togglePause)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.togglePause() }
.store(in: &cancellables)

notificationCenter.publisher(for: .toggleOverlay)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.toggleOverlay() }
.store(in: &cancellables)

notificationCenter.publisher(for: .exportSession)
// File menu export commands carry the format raw value.
.compactMap { ($0.object as? String).flatMap(SessionExporter.Format.init(rawValue:)) }
.receive(on: DispatchQueue.main)
.sink { [weak self] format in self?.exportSession(format) }
.store(in: &cancellables)

notificationCenter.publisher(for: .settingsDidChange)
.compactMap { $0.object as? AppSettings }
.receive(on: DispatchQueue.main)
.sink { [weak self] newSettings in self?.applySettings(newSettings) }
.store(in: &cancellables)
}

// MARK: - Defaults

/// A minimized or closed main window is not visible.
static func mainWindowIsVisible() -> Bool {
NSApp?.windows.contains { $0.identifier?.rawValue == MainWindow.id && $0.isVisible } ?? false
}

/// The production view model. Warms the translation model in the
/// background right away, so the first start() finds it in memory —
/// once per run, not every time the main window reopens.
static func makeAppViewModel() -> TranscriptionViewModel {
let viewModel = TranscriptionViewModel()
Task { await viewModel.prepareModelOnLaunch() }
return viewModel
}
}

/// Hands SwiftUI's `openWindow` action to the coordinator, so AppKit callers
/// (the status bar item, the global hotkeys) can reopen the main window after
/// the user closed it. Menu commands are built at launch and live as long as
/// the app, unlike any view inside the window.
@MainActor
struct MainWindowCommands: Commands {
let coordinator: AppCoordinator
@Environment(\.openWindow) private var openWindow

var body: some Commands {
let openWindow = self.openWindow
let _ = coordinator.setMainWindowOpener { openWindow(id: MainWindow.id) }
EmptyCommands()
}
}
17 changes: 7 additions & 10 deletions macos/LCTMac/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func appLog(_ message: String) {
/// Application delegate for handling app lifecycle events
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
/// Owns the view model and handles capture/pause/overlay commands for
/// the whole run, including while the main window is closed.
let coordinator = AppCoordinator()

private var statusItem: NSStatusItem?
private var overlayWindow: NSWindow?

Expand Down Expand Up @@ -95,13 +99,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Register system-wide hotkeys (⌃⌥⌘ S/P/O)
GlobalHotKeyManager.shared.registerDefaults()

// Activate the app and bring window to front
// Activate the app and bring the main window to front
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
// Ensure the main window is visible
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
window.makeKeyAndOrderFront(nil)
}
self.coordinator.showMainWindow()
}
}

Expand Down Expand Up @@ -220,10 +220,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

@objc private func showMainWindow() {
NSApplication.shared.activate(ignoringOtherApps: true)
if let window = NSApplication.shared.windows.first {
window.makeKeyAndOrderFront(nil)
}
coordinator.showMainWindow()
}

@objc private func toggleOverlay() {
Expand Down
4 changes: 2 additions & 2 deletions macos/LCTMac/App/GlobalHotKeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import Carbon.HIToolbox
/// Unlike NSEvent global monitors, this needs no Accessibility/Input-Monitoring
/// permission — the OS notifies us only when our specific chord fires, rather
/// than us observing every keystroke. Hotkey presses post the same
/// Notifications the in-app menu/commands already use, so there's no coupling
/// to the view model.
/// Notifications the in-app menu/commands already use; AppCoordinator handles
/// them whether or not the main window is open.
///
/// Default chords (control-option-command + key, chosen to avoid clashing with
/// system shortcuts like ⌘Space Spotlight):
Expand Down
16 changes: 10 additions & 6 deletions macos/LCTMac/App/LCTMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ struct LCTMacApp: App {
}()

var body: some Scene {
// Main Window
WindowGroup {
// Main Window — a single Window, not a WindowGroup: the app has one
// view model (owned by AppCoordinator), and closing this window only
// hides it, so the coordinator can bring it back for menu, status bar
// and hotkey commands.
Window("LCT", id: MainWindow.id) {
ZStack {
if showWelcome {
WelcomeView {
Expand All @@ -23,7 +26,7 @@ struct LCTMacApp: App {
}
.transition(.opacity)
} else {
MainView()
MainView(viewModel: appDelegate.coordinator.viewModel)
.transition(.opacity)
.onAppear {
print("[LCTMacApp] MainView appeared")
Expand All @@ -36,7 +39,8 @@ struct LCTMacApp: App {
.windowResizability(.contentMinSize)
.defaultPosition(.center)
.commands {

MainWindowCommands(coordinator: appDelegate.coordinator)

// Edit commands
CommandGroup(after: .pasteboard) {
Button("Copy Translation") {
Expand Down Expand Up @@ -108,8 +112,8 @@ struct SettingsWindowView: View {
SettingsView(settings: $settings) { newSettings in
newSettings.save()
settings = newSettings
// Let the running MainView apply the change immediately; the
// view model never re-posts this notification, so no loop.
// Let the running view model apply the change immediately (via
// AppCoordinator); it never re-posts this notification, so no loop.
NotificationCenter.default.post(name: .settingsDidChange, object: newSettings)
}
}
Expand Down
6 changes: 3 additions & 3 deletions macos/LCTMac/ViewModels/TranscriptionVM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,9 @@ class TranscriptionViewModel: ObservableObject {
/// Warm the translation model right after launch so the first start()
/// doesn't pay the cold-load cost. Local Ollama only, and only after
/// onboarding completed. Failures are logged, never shown — start() has
/// its own checks with actionable notices. Called from MainView's `.task`,
/// not from init, so tests constructing view models never hit a real
/// Ollama.
/// its own checks with actionable notices. Called once by AppCoordinator
/// when it creates the app's view model, not from init, so tests
/// constructing view models never hit a real Ollama.
func prepareModelOnLaunch() async {
guard AppSettings.hasCompletedSetup else { return }
guard settings.isLocalOllama, settings.validatedOllamaEndpoint != nil else { return }
Expand Down
45 changes: 7 additions & 38 deletions macos/LCTMac/Views/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ enum LanguageLabel {
/// Main application view with transcription and translation display
@MainActor
struct MainView: View {
@StateObject private var viewModel = TranscriptionViewModel()
@StateObject private var overlayController = OverlayWindowController()
/// Owned by AppCoordinator, so it outlives this window.
@ObservedObject var viewModel: TranscriptionViewModel
@State private var showSettings = false
@State private var showHistory = false
@State private var autoScroll = true
Expand Down Expand Up @@ -83,51 +83,20 @@ struct MainView: View {
}
.animation(.easeInOut(duration: 0.3), value: viewModel.notice)
.task {
// Warm the translation model in the background right after the
// main window appears, so the first start() finds it in memory.
await viewModel.prepareModelOnLaunch()
// Populate the language menu's availability markers (which
// languages need an on-device model download).
// languages need an on-device model download). The model prewarm
// runs once in AppCoordinator, not on every window appearance.
await viewModel.refreshLanguageAvailability()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { notification in
// The ⌘, settings window saved new settings — apply them to the
// running app. updateSettings never re-posts this notification.
guard let newSettings = notification.object as? AppSettings,
newSettings != viewModel.settings else { return }
viewModel.updateSettings(newSettings)
}
.onReceive(NotificationCenter.default.publisher(for: .toggleCapture)) { notification in
Task {
if let shouldStart = notification.object as? Bool {
if shouldStart {
await viewModel.start()
} else {
await viewModel.stop()
}
} else {
await viewModel.toggleCapture()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .togglePause)) { _ in
viewModel.togglePause()
}
// Start/stop, pause, overlay, settings-change and File-menu export
// notifications are handled by AppCoordinator, which outlives this
// window; only window-scoped commands are handled here.
.onReceive(NotificationCenter.default.publisher(for: .copyTranslation)) { _ in
viewModel.copyToClipboard()
}
.onReceive(NotificationCenter.default.publisher(for: .toggleOverlay)) { _ in
overlayController.toggle(with: viewModel)
}
.onReceive(NotificationCenter.default.publisher(for: .showHistory)) { _ in
showHistory = true
}
.onReceive(NotificationCenter.default.publisher(for: .exportSession)) { notification in
// File menu export commands carry the format raw value.
guard let raw = notification.object as? String,
let format = SessionExporter.Format(rawValue: raw) else { return }
viewModel.exportSession(format)
}
}

// MARK: - HUD Bar (always visible)
Expand Down
Loading
Loading