WhisperaApp.swift•23.8 kB
import SwiftUI
import AppKit
@main
struct WhisperaApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
SettingsView(
permissionManager: appDelegate.permissionManager ?? PermissionManager(),
updateManager: appDelegate.updateManager ?? UpdateManager(),
appLibraryManager: appDelegate.appLibraryManager ?? AppLibraryManager()
)
}
.windowResizability(.automatic)
.windowToolbarStyle(.unified(showsTitle: true))
.defaultPosition(.center)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About Whispera") {
NSApplication.shared.orderFrontStandardAboutPanel(
options: [
.applicationName: "Whispera",
.applicationVersion: AppVersion.Constants.currentVersionString
]
)
}
}
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var statusItem: NSStatusItem?
var popover = NSPopover()
var audioManager: AudioManager!
var shortcutManager: GlobalShortcutManager!
var fileTranscriptionManager: FileTranscriptionManager!
var networkDownloader: NetworkFileDownloader!
var queueManager: TranscriptionQueueManager!
var updateManager: UpdateManager?
var permissionManager: PermissionManager?
var appLibraryManager: AppLibraryManager?
@AppStorage("globalShortcut") var globalShortcut = "⌥⌘R"
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding = false
private var recordingObserver: NSObjectProtocol?
private var downloadObserver: NSObjectProtocol?
private var modelStateObserver: NSObjectProtocol?
private var updateObserver: NSObjectProtocol?
private var onboardingWindow: NSWindow?
private var liveTranscriptionWindow: LiveTranscriptionWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
if shouldTerminateDuplicateInstances() {
AppLogger.shared.general.info("🚫 Another instance is already running. Activating existing instance and terminating this one.")
activateExistingInstance()
NSApp.terminate(nil)
return
}
setupDefaultsIfNeeded()
Task { @MainActor in
audioManager = AudioManager()
shortcutManager = GlobalShortcutManager()
fileTranscriptionManager = FileTranscriptionManager()
networkDownloader = NetworkFileDownloader()
queueManager = TranscriptionQueueManager(
fileTranscriptionManager: fileTranscriptionManager,
networkDownloader: networkDownloader
)
updateManager = UpdateManager()
permissionManager = PermissionManager()
appLibraryManager = AppLibraryManager()
setupMenuBar()
NSApp.setActivationPolicy(.accessory)
shortcutManager.setAudioManager(audioManager)
shortcutManager.setFileTranscriptionManager(fileTranscriptionManager)
shortcutManager.setNetworkDownloader(networkDownloader)
shortcutManager.setQueueManager(queueManager)
observeRecordingState()
observeWindowState()
observeUpdateState()
liveTranscriptionWindow = LiveTranscriptionWindow()
if !hasCompletedOnboarding {
showOnboarding()
}
if updateManager?.autoCheckForUpdates == true {
Task {
do {
let hasUpdate = try await updateManager?.checkForUpdates() ?? false
if hasUpdate {
AppLogger.shared.general.info("🆕 Update available: \(self.updateManager?.latestVersion ?? "unknown")")
} else {
AppLogger.shared.general.info("✅ App is up to date")
}
} catch {
AppLogger.shared.general.info("⚠️ Failed to check for updates: \(error)")
}
}
}
// Listen for show onboarding requests from settings
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ShowOnboarding"),
object: nil,
queue: .main
) { [weak self] _ in
self?.showOnboarding()
}
// Listen for activation requests from other instances
DistributedNotificationCenter.default().addObserver(
forName: NSNotification.Name("ActivateApp"),
object: nil,
queue: .main
) { [weak self] _ in
self?.activateApp()
}
}
}
private func setupDefaultsIfNeeded() {
// Set default values if they don't exist
if UserDefaults.standard.object(forKey: "selectedModel") == nil {
UserDefaults.standard.set("openai_whisper-small.en", forKey: "selectedModel")
}
if UserDefaults.standard.object(forKey: "globalShortcut") == nil {
UserDefaults.standard.set("⌥⌘R", forKey: "globalShortcut")
}
if UserDefaults.standard.object(forKey: "startSound") == nil {
UserDefaults.standard.set("Tink", forKey: "startSound")
}
if UserDefaults.standard.object(forKey: "stopSound") == nil {
UserDefaults.standard.set("Pop", forKey: "stopSound")
}
if UserDefaults.standard.object(forKey: "launchAtStartup") == nil {
UserDefaults.standard.set(false, forKey: "launchAtStartup")
}
if UserDefaults.standard.object(forKey: "soundFeedback") == nil {
UserDefaults.standard.set(true, forKey: "soundFeedback")
}
AppLogger.shared.general.info("🔧 Setup defaults - Model: \(UserDefaults.standard.string(forKey: "selectedModel") ?? "none")")
}
func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "microphone", accessibilityDescription: "Whispera")
button.action = #selector(togglePopover)
button.target = self
}
popover.contentViewController = NSHostingController(rootView: MenuBarView(
audioManager: audioManager,
permissionManager: permissionManager ?? PermissionManager(),
updateManager: updateManager ?? UpdateManager(),
fileTranscriptionManager: fileTranscriptionManager,
networkDownloader: networkDownloader,
queueManager: queueManager
))
popover.behavior = .transient
}
@objc func togglePopover() {
if let button = statusItem?.button {
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
private func showOnboarding() {
let onboardingView = OnboardingView(
audioManager: audioManager,
shortcutManager: shortcutManager
)
let hostingController = NSHostingController(rootView: onboardingView)
onboardingWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 750),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
onboardingWindow?.title = "Welcome to Whispera"
onboardingWindow?.contentViewController = hostingController
onboardingWindow?.center()
onboardingWindow?.makeKeyAndOrderFront(nil)
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NotificationCenter.default.addObserver(
forName: NSNotification.Name("OnboardingCompleted"),
object: nil,
queue: .main
) { [weak self] _ in
NSApp.setActivationPolicy(.accessory)
self?.onboardingWindow?.close()
Task { @MainActor in
self?.applyStoredModel()
}
}
}
private func observeRecordingState() {
recordingObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("RecordingStateChanged"),
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
// Also observe download state changes
downloadObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("DownloadStateChanged"),
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
// Observe model state changes for menu bar updates
modelStateObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("WhisperKitModelStateChanged"),
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
// Observe file transcription notifications
NotificationCenter.default.addObserver(
forName: .fileTranscriptionSuccess,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
NotificationCenter.default.addObserver(
forName: .fileTranscriptionError,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
// Observe queue processing state changes
NotificationCenter.default.addObserver(
forName: NSNotification.Name("QueueProcessingStateChanged"),
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.updateStatusIcon()
}
}
}
private func observeWindowState() {
// Monitor when settings/preferences windows close to revert to accessory mode
NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: nil,
queue: .main
) { notification in
if let window = notification.object as? NSWindow {
let title = window.title.lowercased()
if title.contains("settings") || title.contains("preferences") {
// Settings window is closing, revert to accessory mode
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
NSApp.setActivationPolicy(.accessory)
}
}
}
}
}
@MainActor
private func updateStatusIcon() {
if let button = statusItem?.button {
let whisperKit = audioManager.whisperKitTranscriber
// Clean up any previous subviews and stop any animations
button.subviews.removeAll()
button.layer?.removeAllAnimations()
if permissionManager?.needsPermissions == true {
// Permission warning state - orange exclamation mark with pulse
button.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Whispera - Permissions Required")
button.image?.isTemplate = true
button.alphaValue = 1.0
// Add warning pulse animation
addPermissionWarningAnimation(to: button)
} else if whisperKit.isDownloadingModel {
// Downloading state - rotating download icon to indicate progress
button.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: "Whispera - Downloading")
button.image?.isTemplate = true
button.alphaValue = 1.0
// Add continuous rotation animation to indicate download
addDownloadAnimation(to: button)
} else if networkDownloader?.isDownloading == true {
// Network downloading state - arrow down with rotation
button.image = NSImage(systemSymbolName: "arrow.down.circle", accessibilityDescription: "Whispera - Downloading")
button.image?.isTemplate = true
button.alphaValue = 1.0
// Add download animation
addDownloadAnimation(to: button)
} else if audioManager.isTranscribing || fileTranscriptionManager?.isTranscribing == true || queueManager?.isProcessing == true {
// Transcribing state - waveform icon with subtle pulse
button.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Whispera - Transcribing")
button.image?.isTemplate = true
button.alphaValue = 1.0
// Add gentle pulsing for transcription
addTranscriptionAnimation(to: button)
} else if audioManager.isRecording {
// Recording state - filled microphone icon with stronger pulse
button.image = NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "Whispera - Recording")
button.image?.isTemplate = true
// Add a stronger pulsing animation to show active recording
addRecordingAnimation(to: button)
} else {
// Ready state - default microphone icon, no animation
button.image = NSImage(systemSymbolName: "microphone", accessibilityDescription: "Whispera")
button.image?.isTemplate = true
button.alphaValue = 1.0
}
}
}
private func addDownloadAnimation(to button: NSStatusBarButton) {
// Use NSAnimationContext instead of Core Animation for status bar buttons
button.alphaValue = 1.0
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.8
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 0.3
} completionHandler: {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.8
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 1.0
} completionHandler: {
// Continue animation if still downloading
Task { @MainActor in
if self.audioManager.whisperKitTranscriber.isDownloadingModel || self.networkDownloader?.isDownloading == true {
self.addDownloadAnimation(to: button)
}
}
}
}
}
private func addPermissionWarningAnimation(to button: NSStatusBarButton) {
// Warning pulse for permissions - faster and more urgent than other animations
button.alphaValue = 1.0
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.6
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 0.5
} completionHandler: {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.6
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 1.0
} completionHandler: {
// Continue animation if still needs permissions
if self.permissionManager?.needsPermissions == true {
self.addPermissionWarningAnimation(to: button)
}
}
}
}
private func addTranscriptionAnimation(to button: NSStatusBarButton) {
// Gentle pulsing for transcription
button.alphaValue = 1.0
NSAnimationContext.runAnimationGroup { context in
context.duration = 1.5
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 0.7
} completionHandler: {
NSAnimationContext.runAnimationGroup { context in
context.duration = 1.5
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 1.0
} completionHandler: {
Task { @MainActor in
if self.audioManager.isTranscribing {
self.addTranscriptionAnimation(to: button)
}
}
}
}
}
private func addRecordingAnimation(to button: NSStatusBarButton) {
// Stronger pulsing for recording
button.alphaValue = 1.0
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.8
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 0.4
} completionHandler: {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.8
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
button.animator().alphaValue = 1.0
} completionHandler: {
Task { @MainActor in
if self.audioManager.isRecording {
self.addRecordingAnimation(to: button)
}
}
}
}
}
@MainActor private func applyStoredModel() {
let storedModel = UserDefaults.standard.string(forKey: "selectedModel") ?? "openai_whisper-small.en"
guard audioManager.whisperKitTranscriber.isInitialized else {
AppLogger.shared.general.info("⚠️ WhisperKit not initialized, cannot switch model")
return
}
guard storedModel != audioManager.whisperKitTranscriber.currentModel else {
AppLogger.shared.general.info("📝 Model already matches stored preference: \(storedModel)")
return
}
AppLogger.shared.general.info("🔄 Applying stored model after onboarding: \(storedModel)")
Task {
do {
try await audioManager.whisperKitTranscriber.switchModel(to: storedModel)
AppLogger.shared.general.info("✅ Successfully switched to stored model: \(storedModel)")
} catch {
AppLogger.shared.general.info("❌ Failed to switch to stored model: \(error)")
}
}
}
private func observeUpdateState() {
// Observe update availability notifications
updateObserver = NotificationCenter.default.addObserver(
forName: UpdateManager.updateAvailableNotification,
object: nil,
queue: .main
) { [weak self] notification in
if let version = notification.userInfo?["version"] as? String {
self?.showUpdateAvailableNotification(version: version)
}
}
}
private func showUpdateAvailableNotification(version: String) {
let alert = NSAlert()
alert.messageText = "Update Available"
alert.informativeText = "Whispera \(version) is available. Would you like to download it now?"
alert.addButton(withTitle: "Download")
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "View Release Notes")
let response = alert.runModal()
switch response {
case .alertFirstButtonReturn:
// Download update
Task {
do {
try await updateManager?.downloadUpdate()
} catch {
AppLogger.shared.general.info("❌ Failed to download update: \(error)")
}
}
case .alertThirdButtonReturn:
// Open GitHub releases page
if let url = URL(string: "https://github.com/\(AppVersion.Constants.githubRepo)/releases") {
NSWorkspace.shared.open(url)
}
default:
break
}
}
private func activateApp() {
NSApp.activate(ignoringOtherApps: true)
if let button = statusItem?.button {
if !popover.isShown {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
// MARK: - Single Instance Management
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if let button = statusItem?.button {
if !popover.isShown {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
return true
}
private func shouldTerminateDuplicateInstances() -> Bool {
let existingInstances = checkForExistingInstances()
return !existingInstances.isEmpty
}
func checkForExistingInstances() -> [NSRunningApplication] {
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
return []
}
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.filter { app in
app.bundleIdentifier == bundleIdentifier && app != NSRunningApplication.current
}
}
private func activateExistingInstance() {
let existingInstances = checkForExistingInstances()
if let existingInstance = existingInstances.first {
existingInstance.activate(options: .activateAllWindows)
let notification = Notification(name: NSNotification.Name("ActivateApp"))
DistributedNotificationCenter.default().post(notification)
}
}
@discardableResult
func terminateDuplicateInstances() -> Bool {
let existingInstances = checkForExistingInstances()
for instance in existingInstances {
instance.terminate()
}
return true
}
deinit {
if let observer = recordingObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = downloadObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = modelStateObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = updateObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}