diff --git a/macos/LCTMac/App/AppCoordinator.swift b/macos/LCTMac/App/AppCoordinator.swift new file mode 100644 index 0000000..35ad645 --- /dev/null +++ b/macos/LCTMac/App/AppCoordinator.swift @@ -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() + + 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() + } +} diff --git a/macos/LCTMac/App/AppDelegate.swift b/macos/LCTMac/App/AppDelegate.swift index 2d3b40f..8fd6e64 100644 --- a/macos/LCTMac/App/AppDelegate.swift +++ b/macos/LCTMac/App/AppDelegate.swift @@ -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? @@ -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() } } @@ -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() { diff --git a/macos/LCTMac/App/GlobalHotKeyManager.swift b/macos/LCTMac/App/GlobalHotKeyManager.swift index 8192bab..846107e 100644 --- a/macos/LCTMac/App/GlobalHotKeyManager.swift +++ b/macos/LCTMac/App/GlobalHotKeyManager.swift @@ -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): diff --git a/macos/LCTMac/App/LCTMacApp.swift b/macos/LCTMac/App/LCTMacApp.swift index 0b66ff5..327c1eb 100644 --- a/macos/LCTMac/App/LCTMacApp.swift +++ b/macos/LCTMac/App/LCTMacApp.swift @@ -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 { @@ -23,7 +26,7 @@ struct LCTMacApp: App { } .transition(.opacity) } else { - MainView() + MainView(viewModel: appDelegate.coordinator.viewModel) .transition(.opacity) .onAppear { print("[LCTMacApp] MainView appeared") @@ -36,7 +39,8 @@ struct LCTMacApp: App { .windowResizability(.contentMinSize) .defaultPosition(.center) .commands { - + MainWindowCommands(coordinator: appDelegate.coordinator) + // Edit commands CommandGroup(after: .pasteboard) { Button("Copy Translation") { @@ -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) } } diff --git a/macos/LCTMac/ViewModels/TranscriptionVM.swift b/macos/LCTMac/ViewModels/TranscriptionVM.swift index 0c6df6b..0c12ad2 100644 --- a/macos/LCTMac/ViewModels/TranscriptionVM.swift +++ b/macos/LCTMac/ViewModels/TranscriptionVM.swift @@ -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 } diff --git a/macos/LCTMac/Views/MainView.swift b/macos/LCTMac/Views/MainView.swift index fd96b05..0444710 100644 --- a/macos/LCTMac/Views/MainView.swift +++ b/macos/LCTMac/Views/MainView.swift @@ -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 @@ -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) diff --git a/macos/Tests/LCTMacTests/AppCoordinatorTests.swift b/macos/Tests/LCTMacTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..2e05160 --- /dev/null +++ b/macos/Tests/LCTMacTests/AppCoordinatorTests.swift @@ -0,0 +1,238 @@ +import XCTest +@testable import LCTMac + +/// Records which capture entry point was called instead of touching any real +/// capture service. +@MainActor +private final class RecordingViewModel: TranscriptionViewModel { + var onCall: ((String) -> Void)? + + override func start() async { onCall?("start") } + override func stop() async { onCall?("stop") } + override func toggleCapture() async { onCall?("toggleCapture") } + override func exportSession(_ format: SessionExporter.Format) { onCall?("export:\(format.rawValue)") } +} + +/// Stands in for the main window and onboarding state, and logs window and +/// capture calls in the order they happen. +@MainActor +private final class CoordinatorHarness { + var events: [String] = [] + var windowVisible = false + var setupComplete = true + var createdViewModels: [RecordingViewModel] = [] + let notificationCenter = NotificationCenter() + + func makeCoordinator() -> AppCoordinator { + let coordinator = AppCoordinator( + notificationCenter: notificationCenter, + isSetupComplete: { self.setupComplete }, + isMainWindowVisible: { self.windowVisible }, + makeViewModel: { + let viewModel = RecordingViewModel() + viewModel.onCall = { self.events.append($0) } + self.createdViewModels.append(viewModel) + return viewModel + } + ) + coordinator.setMainWindowOpener { + self.events.append("showMainWindow") + self.windowVisible = true + } + return coordinator + } +} + +/// Tests that start/stop, pause and settings commands reach the app's view +/// model whether or not the main window is open. +@MainActor +final class AppCoordinatorTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "LCTMacSettings") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "LCTMacSettings") + super.tearDown() + } + + /// Poll until `condition` holds or the deadline passes; returns the final value. + private func waitFor(timeout: TimeInterval = 2, _ condition: () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } + + // MARK: - Notification decoding + + func testCaptureCommand_NotificationObject_MapsToStartStopToggle() { + XCTAssertEqual(CaptureCommand(notificationObject: true), .start, "status bar Start Capture") + XCTAssertEqual(CaptureCommand(notificationObject: false), .stop, "status bar Stop Capture") + XCTAssertEqual(CaptureCommand(notificationObject: nil), .toggle, "Window menu / global hotkey") + XCTAssertEqual(CaptureCommand(notificationObject: "unexpected"), .toggle) + } + + // MARK: - Main window closed + + func testPerform_MainWindowClosed_ShowsWindowThenDelivers() async { + let cases: [(CaptureCommand, String)] = [(.start, "start"), (.stop, "stop"), (.toggle, "toggleCapture")] + for (command, expectedCall) in cases { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + + await coordinator.perform(command) + + XCTAssertEqual(harness.events, ["showMainWindow", expectedCall], "\(command)") + } + } + + /// The reported bug: with the main window closed nothing observed + /// `.toggleCapture`, so the Window menu, status bar and hotkey did nothing. + func testToggleCaptureNotification_MainWindowClosed_IsDelivered() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + + harness.notificationCenter.post(name: .toggleCapture, object: nil) + + let delivered = await waitFor { harness.events == ["showMainWindow", "toggleCapture"] } + XCTAssertTrue(delivered, "events: \(harness.events)") + withExtendedLifetime(coordinator) {} + } + + func testStartCaptureNotification_MainWindowClosed_StartsCapture() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + + harness.notificationCenter.post(name: .toggleCapture, object: true) + + let delivered = await waitFor { harness.events == ["showMainWindow", "start"] } + XCTAssertTrue(delivered, "events: \(harness.events)") + withExtendedLifetime(coordinator) {} + } + + // MARK: - Main window open + + func testPerform_MainWindowVisible_DeliversWithoutShowingWindow() async { + let harness = CoordinatorHarness() + harness.windowVisible = true + let coordinator = harness.makeCoordinator() + + await coordinator.perform(.toggle) + + XCTAssertEqual(harness.events, ["toggleCapture"], "a visible window must not be re-activated (the hotkey works from other apps)") + } + + // MARK: - Onboarding + + func testPerform_SetupNotCompleted_ShowsWindowWithoutCreatingViewModel() async { + let harness = CoordinatorHarness() + harness.setupComplete = false + let coordinator = harness.makeCoordinator() + + await coordinator.perform(.start) + + XCTAssertEqual(harness.events, ["showMainWindow"], "onboarding is shown; there is no session to start") + XCTAssertTrue(harness.createdViewModels.isEmpty, "the view model must load the settings onboarding saves, so it is created only afterwards") + } + + // MARK: - View model lifetime + + func testViewModel_SurvivesMainWindowCloseAndReopen() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + let firstViewModel = coordinator.viewModel + + await coordinator.perform(.start) + harness.windowVisible = false // user closes the window mid-session + await coordinator.perform(.stop) + + XCTAssertEqual(harness.events, ["showMainWindow", "start", "showMainWindow", "stop"]) + XCTAssertEqual(harness.createdViewModels.count, 1) + XCTAssertTrue(coordinator.viewModel === firstViewModel, "the session must not be replaced when the window reopens") + } + + // MARK: - Export + + /// The session outlives the window, so File ▸ Export must still work + /// after the window was closed. + func testExportSessionNotification_MainWindowClosed_ShowsWindowThenExports() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + let format = SessionExporter.Format.srt + + harness.notificationCenter.post(name: .exportSession, object: format.rawValue) + + let delivered = await waitFor { harness.events == ["showMainWindow", "export:\(format.rawValue)"] } + XCTAssertTrue(delivered, "events: \(harness.events)") + withExtendedLifetime(coordinator) {} + } + + func testExportSessionNotification_UnknownFormat_IsIgnored() async { + let harness = CoordinatorHarness() + harness.windowVisible = true + let coordinator = harness.makeCoordinator() + + harness.notificationCenter.post(name: .exportSession, object: "not-a-format") + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertTrue(harness.events.isEmpty, "events: \(harness.events)") + withExtendedLifetime(coordinator) {} + } + + func testExportSession_SetupNotCompleted_DoesNothing() { + let harness = CoordinatorHarness() + harness.setupComplete = false + let coordinator = harness.makeCoordinator() + + coordinator.exportSession(.markdown) + + XCTAssertTrue(harness.events.isEmpty, "events: \(harness.events)") + XCTAssertTrue(harness.createdViewModels.isEmpty, "onboarding has no session to export") + } + + // MARK: - Pause and settings + + func testTogglePauseNotification_MainWindowClosed_ReachesViewModel() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + let viewModel = coordinator.viewModel + XCTAssertFalse(viewModel.isPaused) + + harness.notificationCenter.post(name: .togglePause, object: nil) + + let paused = await waitFor { viewModel.isPaused } + XCTAssertTrue(paused) + XCTAssertTrue(harness.events.isEmpty, "pause must not bring the window back") + withExtendedLifetime(coordinator) {} + } + + func testSettingsDidChangeNotification_UpdatesExistingViewModel() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + let viewModel = coordinator.viewModel + var newSettings = viewModel.settings + newSettings.maxContextEntries += 1 + + harness.notificationCenter.post(name: .settingsDidChange, object: newSettings) + + let applied = await waitFor { viewModel.settings == newSettings } + XCTAssertTrue(applied) + withExtendedLifetime(coordinator) {} + } + + func testSettingsDidChangeNotification_NoViewModelYet_DoesNotCreateOne() async { + let harness = CoordinatorHarness() + let coordinator = harness.makeCoordinator() + + harness.notificationCenter.post(name: .settingsDidChange, object: AppSettings()) + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertTrue(harness.createdViewModels.isEmpty, "a view model created later loads the saved settings itself") + withExtendedLifetime(coordinator) {} + } +}