From ec8ca7a7a9656ab6a3c8ee7be7cb458109c256e5 Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Sat, 10 Oct 2026 22:42:42 +1300 Subject: [PATCH 1/2] fix(macos): handle capture commands while the main window is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start/Stop Capture from the Window menu, the status bar item and the ⌃⌥⌘S hotkey only posted .toggleCapture, whose sole observer was MainView. After the user closed the window (the app keeps running in the menu bar) nothing observed it and the command was silently dropped. Closing the window also destroyed MainView's @StateObject view model, so reopening started a fresh session next to any overlay still bound to the old one. Move ownership to an app-lifetime AppCoordinator on the app delegate: it creates the single TranscriptionViewModel (lazily, after onboarding) and the overlay controller, and observes the capture, pause, overlay and settings-change notifications. A capture command that arrives while the main window is closed or minimized first activates the app and reopens the window, then goes through the unchanged start()/stop()/toggleCapture() state machine; with the window on screen nothing steals focus, as before. The main scene becomes a single-instance Window so closing only hides it and SwiftUI's openWindow (captured from a Commands environment) reopens the same window from AppKit callers. Model prewarm now runs once per view model instead of on every window appearance. Co-Authored-By: Claude Opus 5.5 --- macos/LCTMac/App/AppCoordinator.swift | 195 +++++++++++++++++ macos/LCTMac/App/AppDelegate.swift | 17 +- macos/LCTMac/App/GlobalHotKeyManager.swift | 4 +- macos/LCTMac/App/LCTMacApp.swift | 16 +- macos/LCTMac/ViewModels/TranscriptionVM.swift | 6 +- macos/LCTMac/Views/MainView.swift | 38 +--- .../LCTMacTests/AppCoordinatorTests.swift | 198 ++++++++++++++++++ 7 files changed, 420 insertions(+), 54 deletions(-) create mode 100644 macos/LCTMac/App/AppCoordinator.swift create mode 100644 macos/Tests/LCTMacTests/AppCoordinatorTests.swift diff --git a/macos/LCTMac/App/AppCoordinator.swift b/macos/LCTMac/App/AppCoordinator.swift new file mode 100644 index 0000000..681f2de --- /dev/null +++ b/macos/LCTMac/App/AppCoordinator.swift @@ -0,0 +1,195 @@ +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, 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) + } + + /// 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: .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 00aa153..7270c68 100644 --- a/macos/LCTMac/App/AppDelegate.swift +++ b/macos/LCTMac/App/AppDelegate.swift @@ -49,6 +49,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? @@ -99,13 +103,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Request necessary permissions requestPermissions() - // 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() } } @@ -241,10 +241,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 de79af5..ecb6143 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") { @@ -92,8 +96,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 267c67d..765bf4f 100644 --- a/macos/LCTMac/ViewModels/TranscriptionVM.swift +++ b/macos/LCTMac/ViewModels/TranscriptionVM.swift @@ -345,9 +345,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 e52c0b6..c577015 100644 --- a/macos/LCTMac/Views/MainView.swift +++ b/macos/LCTMac/Views/MainView.swift @@ -16,8 +16,8 @@ enum HUD { /// 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 @@ -46,40 +46,12 @@ struct MainView: View { HistoryView(viewModel: viewModel) } .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() - } - .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 and settings-change 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 } diff --git a/macos/Tests/LCTMacTests/AppCoordinatorTests.swift b/macos/Tests/LCTMacTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..ed4ffd0 --- /dev/null +++ b/macos/Tests/LCTMacTests/AppCoordinatorTests.swift @@ -0,0 +1,198 @@ +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") } +} + +/// 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: - 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) {} + } +} From fb6c4ceb3af01f687728fa88582cb49d8aa80803 Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Sun, 11 Oct 2026 11:55:24 +1300 Subject: [PATCH 2/2] =?UTF-8?q?fix(macos):=20handle=20File=20=E2=96=B8=20E?= =?UTF-8?q?xport=20while=20the=20main=20window=20is=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session now outlives the main window (AppCoordinator owns the view model), but the .exportSession observer still lived in MainView, so File ▸ Export (⌘E) did nothing after the window was closed. Move it to the coordinator alongside the other app-level commands: it brings the window back so the result notice is visible, then exports. Co-Authored-By: Claude Opus 5.5 --- macos/LCTMac/App/AppCoordinator.swift | 22 +++++++++- macos/LCTMac/Views/MainView.swift | 12 ++---- .../LCTMacTests/AppCoordinatorTests.swift | 40 +++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/macos/LCTMac/App/AppCoordinator.swift b/macos/LCTMac/App/AppCoordinator.swift index 681f2de..35ad645 100644 --- a/macos/LCTMac/App/AppCoordinator.swift +++ b/macos/LCTMac/App/AppCoordinator.swift @@ -28,8 +28,8 @@ enum CaptureCommand: Equatable { /// 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, and settings changes from the menu bar, the status bar -/// item, the global hotkeys, and the Settings window. +/// 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, @@ -127,6 +127,17 @@ final class AppCoordinator { 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. @@ -154,6 +165,13 @@ final class AppCoordinator { .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) diff --git a/macos/LCTMac/Views/MainView.swift b/macos/LCTMac/Views/MainView.swift index 2ca59e1..0444710 100644 --- a/macos/LCTMac/Views/MainView.swift +++ b/macos/LCTMac/Views/MainView.swift @@ -88,21 +88,15 @@ struct MainView: View { // runs once in AppCoordinator, not on every window appearance. await viewModel.refreshLanguageAvailability() } - // Start/stop, pause, overlay and settings-change notifications are - // handled by AppCoordinator, which outlives this window; only - // window-scoped commands are handled here. + // 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: .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 index ed4ffd0..2e05160 100644 --- a/macos/Tests/LCTMacTests/AppCoordinatorTests.swift +++ b/macos/Tests/LCTMacTests/AppCoordinatorTests.swift @@ -10,6 +10,7 @@ private final class RecordingViewModel: TranscriptionViewModel { 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 @@ -155,6 +156,45 @@ final class AppCoordinatorTests: XCTestCase { 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 {