- Remove try! force operation that could crash on in-memory container failure - Implement cascading fallback strategy with 3 initialization attempts: 1. Persistent storage (normal operation) 2. In-memory storage with user warning 3. Ultra-minimal default container - Add containerInitializationFailed flag to track critical failures - Extract container creation into static helper methods for better error handling - Show user-friendly error dialog and graceful termination on total failure - Only use preconditionFailure as absolute last resort after all attempts fail Addresses AI code reviewer feedback about unsafe force operations. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
354 lines
16 KiB
Swift
354 lines
16 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Sparkle
|
|
import AppKit
|
|
import OSLog
|
|
import AppIntents
|
|
import FluidAudio
|
|
|
|
@main
|
|
struct VoiceInkApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
let container: ModelContainer
|
|
let containerInitializationFailed: Bool
|
|
|
|
@StateObject private var whisperState: WhisperState
|
|
@StateObject private var hotkeyManager: HotkeyManager
|
|
@StateObject private var updaterViewModel: UpdaterViewModel
|
|
@StateObject private var menuBarManager: MenuBarManager
|
|
@StateObject private var aiService = AIService()
|
|
@StateObject private var enhancementService: AIEnhancementService
|
|
@StateObject private var activeWindowService = ActiveWindowService.shared
|
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
|
@AppStorage("enableAnnouncements") private var enableAnnouncements = true
|
|
|
|
// Audio cleanup manager for automatic deletion of old audio files
|
|
private let audioCleanupManager = AudioCleanupManager.shared
|
|
|
|
// Transcription auto-cleanup service for zero data retention
|
|
private let transcriptionAutoCleanupService = TranscriptionAutoCleanupService.shared
|
|
|
|
init() {
|
|
// Configure FluidAudio logging subsystem
|
|
AppLogger.defaultSubsystem = "com.prakashjoshipax.voiceink.parakeet"
|
|
|
|
if UserDefaults.standard.object(forKey: "powerModeUIFlag") == nil {
|
|
let hasEnabledPowerModes = PowerModeManager.shared.configurations.contains { $0.isEnabled }
|
|
UserDefaults.standard.set(hasEnabledPowerModes, forKey: "powerModeUIFlag")
|
|
}
|
|
|
|
let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Initialization")
|
|
let schema = Schema([Transcription.self])
|
|
var initializationFailed = false
|
|
|
|
// Attempt 1: Try persistent storage
|
|
if let persistentContainer = Self.createPersistentContainer(schema: schema, logger: logger) {
|
|
container = persistentContainer
|
|
|
|
#if DEBUG
|
|
// Print SwiftData storage location in debug builds only
|
|
if let url = persistentContainer.mainContext.container.configurations.first?.url {
|
|
print("💾 SwiftData storage location: \(url.path)")
|
|
}
|
|
#endif
|
|
}
|
|
// Attempt 2: Try in-memory storage
|
|
else if let memoryContainer = Self.createInMemoryContainer(schema: schema, logger: logger) {
|
|
container = memoryContainer
|
|
|
|
logger.warning("Using in-memory storage as fallback. Data will not persist between sessions.")
|
|
|
|
// Show alert to user about storage issue
|
|
DispatchQueue.main.async {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Storage Warning"
|
|
alert.informativeText = "VoiceInk couldn't access its storage location. Your transcriptions will not be saved between sessions."
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
}
|
|
// Attempt 3: Try ultra-minimal default container
|
|
else if let minimalContainer = Self.createMinimalContainer(schema: schema, logger: logger) {
|
|
container = minimalContainer
|
|
logger.warning("Using minimal emergency container")
|
|
}
|
|
// All attempts failed: Create disabled container and mark for termination
|
|
else {
|
|
logger.critical("All ModelContainer initialization attempts failed")
|
|
initializationFailed = true
|
|
|
|
// Create a dummy container to satisfy Swift's initialization requirements
|
|
// App will show error and terminate in onAppear
|
|
container = Self.createDummyContainer(schema: schema)
|
|
}
|
|
|
|
containerInitializationFailed = initializationFailed
|
|
|
|
// Initialize services with proper sharing of instances
|
|
let aiService = AIService()
|
|
_aiService = StateObject(wrappedValue: aiService)
|
|
|
|
let updaterViewModel = UpdaterViewModel()
|
|
_updaterViewModel = StateObject(wrappedValue: updaterViewModel)
|
|
|
|
let enhancementService = AIEnhancementService(aiService: aiService, modelContext: container.mainContext)
|
|
_enhancementService = StateObject(wrappedValue: enhancementService)
|
|
|
|
let whisperState = WhisperState(modelContext: container.mainContext, enhancementService: enhancementService)
|
|
_whisperState = StateObject(wrappedValue: whisperState)
|
|
|
|
let hotkeyManager = HotkeyManager(whisperState: whisperState)
|
|
_hotkeyManager = StateObject(wrappedValue: hotkeyManager)
|
|
|
|
let menuBarManager = MenuBarManager()
|
|
_menuBarManager = StateObject(wrappedValue: menuBarManager)
|
|
appDelegate.menuBarManager = menuBarManager
|
|
|
|
let activeWindowService = ActiveWindowService.shared
|
|
activeWindowService.configure(with: enhancementService)
|
|
activeWindowService.configureWhisperState(whisperState)
|
|
_activeWindowService = StateObject(wrappedValue: activeWindowService)
|
|
|
|
// Ensure no lingering recording state from previous runs
|
|
Task {
|
|
await whisperState.resetOnLaunch()
|
|
}
|
|
|
|
AppShortcuts.updateAppShortcutParameters()
|
|
}
|
|
|
|
// MARK: - Container Creation Helpers
|
|
|
|
private static func createPersistentContainer(schema: Schema, logger: Logger) -> ModelContainer? {
|
|
do {
|
|
// Create app-specific Application Support directory URL
|
|
let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("com.prakashjoshipax.VoiceInk", isDirectory: true)
|
|
|
|
// Create the directory if it doesn't exist
|
|
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
|
|
|
|
// Configure SwiftData to use the conventional location
|
|
let storeURL = appSupportURL.appendingPathComponent("default.store")
|
|
let modelConfiguration = ModelConfiguration(schema: schema, url: storeURL)
|
|
|
|
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
|
} catch {
|
|
logger.error("Failed to create persistent ModelContainer: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func createInMemoryContainer(schema: Schema, logger: Logger) -> ModelContainer? {
|
|
do {
|
|
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
|
return try ModelContainer(for: schema, configurations: [configuration])
|
|
} catch {
|
|
logger.error("Failed to create in-memory ModelContainer: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func createMinimalContainer(schema: Schema, logger: Logger) -> ModelContainer? {
|
|
do {
|
|
// Try default initializer without custom configuration
|
|
return try ModelContainer(for: schema)
|
|
} catch {
|
|
logger.error("Failed to create minimal ModelContainer: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func createDummyContainer(schema: Schema) -> ModelContainer {
|
|
// Create an absolute minimal container for initialization
|
|
// This uses in-memory storage and will never actually be used
|
|
// as the app will show an error and terminate in onAppear
|
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
|
|
|
// Note: In-memory containers should always succeed unless SwiftData itself is unavailable
|
|
// (which would indicate a serious system-level issue). We use preconditionFailure here
|
|
// rather than fatalError because:
|
|
// 1. This code is only reached after 3 prior initialization attempts have failed
|
|
// 2. An in-memory container failing indicates SwiftData is completely unavailable
|
|
// 3. Swift requires non-optional container property to be initialized
|
|
// 4. The app will immediately terminate in onAppear when containerInitializationFailed is checked
|
|
do {
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
} catch {
|
|
// This indicates a system-level SwiftData failure - app cannot function
|
|
preconditionFailure("Unable to create even a dummy ModelContainer. SwiftData is unavailable: \(error)")
|
|
}
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
if hasCompletedOnboarding {
|
|
ContentView()
|
|
.environmentObject(whisperState)
|
|
.environmentObject(hotkeyManager)
|
|
.environmentObject(updaterViewModel)
|
|
.environmentObject(menuBarManager)
|
|
.environmentObject(aiService)
|
|
.environmentObject(enhancementService)
|
|
.modelContainer(container)
|
|
.onAppear {
|
|
// Check if container initialization failed
|
|
if containerInitializationFailed {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Critical Storage Error"
|
|
alert.informativeText = "VoiceInk cannot initialize its storage system. The app cannot continue.\n\nPlease try reinstalling the app or contact support if the issue persists."
|
|
alert.alertStyle = .critical
|
|
alert.addButton(withTitle: "Quit")
|
|
alert.runModal()
|
|
|
|
NSApplication.shared.terminate(nil)
|
|
return
|
|
}
|
|
|
|
updaterViewModel.silentlyCheckForUpdates()
|
|
if enableAnnouncements {
|
|
AnnouncementsService.shared.start()
|
|
}
|
|
|
|
// Start the transcription auto-cleanup service (handles immediate and scheduled transcript deletion)
|
|
transcriptionAutoCleanupService.startMonitoring(modelContext: container.mainContext)
|
|
|
|
// Start the automatic audio cleanup process only if transcript cleanup is not enabled
|
|
if !UserDefaults.standard.bool(forKey: "IsTranscriptionCleanupEnabled") {
|
|
audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext)
|
|
}
|
|
|
|
// Process any pending open-file request now that the main ContentView is ready.
|
|
if let pendingURL = appDelegate.pendingOpenFileURL {
|
|
NotificationCenter.default.post(name: .navigateToDestination, object: nil, userInfo: ["destination": "Transcribe Audio"])
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
NotificationCenter.default.post(name: .openFileForTranscription, object: nil, userInfo: ["url": pendingURL])
|
|
}
|
|
appDelegate.pendingOpenFileURL = nil
|
|
}
|
|
}
|
|
.background(WindowAccessor { window in
|
|
WindowManager.shared.configureWindow(window)
|
|
})
|
|
.onDisappear {
|
|
AnnouncementsService.shared.stop()
|
|
whisperState.unloadModel()
|
|
|
|
// Stop the transcription auto-cleanup service
|
|
transcriptionAutoCleanupService.stopMonitoring()
|
|
|
|
// Stop the automatic audio cleanup process
|
|
audioCleanupManager.stopAutomaticCleanup()
|
|
}
|
|
} else {
|
|
OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
|
.environmentObject(hotkeyManager)
|
|
.environmentObject(whisperState)
|
|
.environmentObject(aiService)
|
|
.environmentObject(enhancementService)
|
|
.frame(minWidth: 880, minHeight: 780)
|
|
.background(WindowAccessor { window in
|
|
if window.identifier == nil || window.identifier != NSUserInterfaceItemIdentifier("com.prakashjoshipax.voiceink.onboardingWindow") {
|
|
WindowManager.shared.configureOnboardingPanel(window)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
.windowStyle(.hiddenTitleBar)
|
|
.commands {
|
|
CommandGroup(replacing: .newItem) { }
|
|
|
|
CommandGroup(after: .appInfo) {
|
|
CheckForUpdatesView(updaterViewModel: updaterViewModel)
|
|
}
|
|
}
|
|
|
|
MenuBarExtra {
|
|
MenuBarView()
|
|
.environmentObject(whisperState)
|
|
.environmentObject(hotkeyManager)
|
|
.environmentObject(menuBarManager)
|
|
.environmentObject(updaterViewModel)
|
|
.environmentObject(aiService)
|
|
.environmentObject(enhancementService)
|
|
} label: {
|
|
let image: NSImage = {
|
|
let ratio = $0.size.height / $0.size.width
|
|
$0.size.height = 22
|
|
$0.size.width = 22 / ratio
|
|
return $0
|
|
}(NSImage(named: "menuBarIcon")!)
|
|
|
|
Image(nsImage: image)
|
|
}
|
|
.menuBarExtraStyle(.menu)
|
|
|
|
#if DEBUG
|
|
WindowGroup("Debug") {
|
|
Button("Toggle Menu Bar Only") {
|
|
menuBarManager.isMenuBarOnly.toggle()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
class UpdaterViewModel: ObservableObject {
|
|
@AppStorage("autoUpdateCheck") private var autoUpdateCheck = true
|
|
|
|
private let updaterController: SPUStandardUpdaterController
|
|
|
|
@Published var canCheckForUpdates = false
|
|
|
|
init() {
|
|
updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
|
|
|
// Enable automatic update checking
|
|
updaterController.updater.automaticallyChecksForUpdates = autoUpdateCheck
|
|
updaterController.updater.updateCheckInterval = 24 * 60 * 60
|
|
|
|
updaterController.updater.publisher(for: \.canCheckForUpdates)
|
|
.assign(to: &$canCheckForUpdates)
|
|
}
|
|
|
|
func toggleAutoUpdates(_ value: Bool) {
|
|
updaterController.updater.automaticallyChecksForUpdates = value
|
|
}
|
|
|
|
func checkForUpdates() {
|
|
// This is for manual checks - will show UI
|
|
updaterController.checkForUpdates(nil)
|
|
}
|
|
|
|
func silentlyCheckForUpdates() {
|
|
// This checks for updates in the background without showing UI unless an update is found
|
|
updaterController.updater.checkForUpdatesInBackground()
|
|
}
|
|
}
|
|
|
|
struct CheckForUpdatesView: View {
|
|
@ObservedObject var updaterViewModel: UpdaterViewModel
|
|
|
|
var body: some View {
|
|
Button("Check for Updates…", action: updaterViewModel.checkForUpdates)
|
|
.disabled(!updaterViewModel.canCheckForUpdates)
|
|
}
|
|
}
|
|
|
|
struct WindowAccessor: NSViewRepresentable {
|
|
let callback: (NSWindow) -> Void
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let view = NSView()
|
|
DispatchQueue.main.async {
|
|
if let window = view.window {
|
|
callback(window)
|
|
}
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {}
|
|
}
|