Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,6 @@ __pycache__
AudioWhisper.iconset/
.build-uv/
.beads/

# IDE
.idea
8 changes: 4 additions & 4 deletions Sources/App/AppDelegate+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal extension AppDelegate {

DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if AppSetupHelper.checkFirstRun() {
self.showWelcomeAndSettings()
Task { @MainActor in await self.showWelcomeAndSettings() }
}
}
}
Expand All @@ -76,9 +76,9 @@ internal extension AppDelegate {
KeychainService.shared.getQuietly(service: service, account: account) != nil
}

func showWelcomeAndSettings() {
let shouldOpenSettings = WelcomeWindow.showWelcomeDialog()

@MainActor
func showWelcomeAndSettings() async {
let shouldOpenSettings = await WelcomeWindow.showWelcomeDialog()
if shouldOpenSettings {
DashboardWindowManager.shared.showDashboardWindow()
}
Expand Down
6 changes: 1 addition & 5 deletions Sources/App/AppDelegate+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ internal extension AppDelegate {
}

@objc func showHelp() {
let shouldOpenSettings = WelcomeWindow.showWelcomeDialog()

if shouldOpenSettings {
DashboardWindowManager.shared.showDashboardWindow()
}
Task { @MainActor in await showWelcomeAndSettings() }
}

@objc func transcribeAudioFile() {
Expand Down
135 changes: 86 additions & 49 deletions Sources/Managers/Windows/WelcomeWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,96 @@ import AppKit
import SwiftUI

internal class WelcomeWindow {
static func showWelcomeDialog() -> Bool {
// Show the new SwiftUI welcome window
let welcomeView = WelcomeView()
let hostingController = NSHostingController(rootView: welcomeView)

// Get the main screen dimensions for proper centering
let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
let windowWidth: CGFloat = 600
let windowHeight: CGFloat = 650

let window = NSWindow(
contentRect: NSRect(
x: (screenFrame.width - windowWidth) / 2,
y: (screenFrame.height - windowHeight) / 2,
width: windowWidth,
height: windowHeight
),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)

window.contentViewController = hostingController
window.title = "Welcome to AudioWhisper"
window.isReleasedWhenClosed = false

// Add window delegate to handle close button properly
let delegate = WelcomeWindowDelegate()
window.delegate = delegate

// Ensure proper focus and activation
NSApplication.shared.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()

// Force focus after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Tracks whether a dialog is already showing so we don't open two at once.
private static var isShowing = false

/// Shows the welcome dialog as a non-modal window and waits asynchronously for the user to
/// complete or cancel the flow. Returns `true` if the user completed setup, `false` if they
/// closed the window without finishing.
///
/// Using a regular (non-modal) window is critical: `NSApplication.runModal` blocks the main
/// thread in `NSModalPanelRunLoopMode`, which prevents `DispatchQueue.main` callbacks
/// (including Swift Concurrency's `@MainActor` executor) from firing. That would freeze any
/// `async`/`await` work started from within the welcome view.
@MainActor
static func showWelcomeDialog() async -> Bool {
guard !isShowing else { return false }
isShowing = true
defer { isShowing = false }

return await withCheckedContinuation { continuation in
let state = WelcomeCompletionState(continuation: continuation)

let welcomeView = WelcomeView { completed in
state.complete(result: completed)
}
let hostingController = NSHostingController(rootView: welcomeView)

let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
let windowWidth: CGFloat = 600
let windowHeight: CGFloat = 650

let window = NSWindow(
contentRect: NSRect(
x: (screenFrame.width - windowWidth) / 2,
y: (screenFrame.height - windowHeight) / 2,
width: windowWidth,
height: windowHeight
),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.contentViewController = hostingController
window.title = "Welcome to AudioWhisper"
window.isReleasedWhenClosed = false

let delegate = WelcomeWindowDelegate(state: state)
window.delegate = delegate
Comment thread
cursor[bot] marked this conversation as resolved.
state.delegate = delegate
state.window = window

NSApplication.shared.activate(ignoringOtherApps: true)
window.makeKey()
window.makeKeyAndOrderFront(nil)
}

// Run the window modally
let response = NSApplication.shared.runModal(for: window)
window.close()

return response == .OK
}
}

internal class WelcomeWindowDelegate: NSObject, NSWindowDelegate {
// MARK: - Completion state

/// Reference type shared between WelcomeWindow, WelcomeView's completion callback, and the
/// window delegate. Guarantees the continuation is resumed exactly once.
private final class WelcomeCompletionState {
private var resumed = false
private var continuation: CheckedContinuation<Bool, Never>
weak var window: NSWindow?
var delegate: WelcomeWindowDelegate?

init(continuation: CheckedContinuation<Bool, Never>) {
self.continuation = continuation
}

func complete(result: Bool) {
guard !resumed else { return }
resumed = true
window?.close()
window = nil
delegate = nil
continuation.resume(returning: result)
}
}

// MARK: - Window delegate

private class WelcomeWindowDelegate: NSObject, NSWindowDelegate {
private let state: WelcomeCompletionState

init(state: WelcomeCompletionState) {
self.state = state
}

func windowShouldClose(_ sender: NSWindow) -> Bool {
// End the modal session when close button is clicked
NSApplication.shared.stopModal(withCode: .cancel)
return true
state.complete(result: false)
return false // state.complete already closes the window
}
}
}
4 changes: 2 additions & 2 deletions Sources/Models/ModelEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ internal struct LocalWhisperEntry: ModelEntry {
guard let stage = stage else { return nil }
switch stage {
case .failed: return .red
case .preparing, .downloading, .processing, .completing: return .blue
case .preparing, .creatingModelFolder, .checkingExistingModels, .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList, .downloading, .processing, .completing:
return .blue
case .ready: return .green
}
}
Expand Down Expand Up @@ -68,4 +69,3 @@ internal struct MLXEntry: ModelEntry {
return isDownloading ? .blue : nil
}
}

18 changes: 17 additions & 1 deletion Sources/Models/WhisperModel+WhisperKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,21 @@ internal extension WhisperModel {
return "openai_whisper-large-v3_turbo"
}
}
}

var openAIWhisperRepoName: String {
switch self {
case .tiny:
return "openai/whisper-tiny"
case .base:
return "openai/whisper-base"
case .small:
return "openai/whisper-small"
case .largeTurbo:
return "openai/whisper-large-v3-turbo"
}
}

var openAIWhisperRepoURL: URL {
URL(string: "https://huggingface.co/\(openAIWhisperRepoName)")!
}
}
Loading