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
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ let package = Package(
.product(name: "ViewInspector", package: "ViewInspector")
],
path: "Tests/SpeechToTextTests"
),
.testTarget(
name: "SpeechToTextIntegrationTests",
dependencies: [
"SpeechToText"
],
path: "Tests/SpeechToTextIntegrationTests"
)
]
)
105 changes: 100 additions & 5 deletions Sources/SpeechToTextApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import OSLog
import SwiftUI

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var hotkeyService: HotkeyService?
private var onboardingWindow: NSWindow?
private let settingsService = SettingsService()
private var recordingModalObserver: NSObjectProtocol?
private var settingsObserver: NSObjectProtocol?
/// Flag to prevent race condition when showing recording modal
private var isShowingRecordingModal = false

func applicationDidFinishLaunching(_ notification: Notification) {
// Ensure only one instance of the app runs
Expand Down Expand Up @@ -52,6 +54,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return false
}

// MARK: - NSWindowDelegate

/// Properly cleanup window references when user closes window via close button
/// This prevents memory leaks and ensures window can be reopened
nonisolated func windowWillClose(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }

Task { @MainActor [weak self] in
guard let self else { return }

if window === self.onboardingWindow {
self.onboardingWindow = nil
} else if window === self.recordingWindow {
self.recordingWindow = nil
self.recordingViewModel = nil
self.isShowingRecordingModal = false
} else if window === self.settingsWindow {
self.settingsWindow = nil
}
}
}

// MARK: - Menu Action Observers
// Note: Menu bar is handled by MenuBarExtra in SpeechToTextApp.swift
// AppDelegate only handles notification observers for modal/settings windows
Expand Down Expand Up @@ -123,6 +147,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

window.title = "Welcome to Speech-to-Text"
window.contentView = NSHostingView(rootView: contentView)
window.delegate = self // Handle window close via close button
window.center()

// Ensure app is active and window is visible
Expand All @@ -136,19 +161,87 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Recording Modal

private var recordingWindow: NSWindow?
private var recordingViewModel: RecordingViewModel?
private let permissionService = PermissionService()

@MainActor
private func showRecordingModal() {
// Don't show multiple modals
if recordingWindow != nil {
// Don't show multiple modals - check both window and in-progress flag
// The flag prevents race condition during async permission check
if recordingWindow != nil || isShowingRecordingModal {
return
}

// Set flag before async work to prevent concurrent calls
isShowingRecordingModal = true

// Check permissions before showing modal
Task {
await checkPermissionsAndShowModal()
// Reset flag if we didn't show the modal (permissions denied)
if recordingWindow == nil {
isShowingRecordingModal = false
}
}
}

@MainActor
private func checkPermissionsAndShowModal() async {
// Check microphone permission first (required)
let hasMicrophone = await permissionService.checkMicrophonePermission()
if !hasMicrophone {
showPermissionAlert(
title: "Microphone Access Required",
message: "Speech-to-Text needs microphone access to record your voice. Please grant permission in System Settings > Privacy & Security > Microphone.",
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
)
return
}

// Check accessibility permission (required for text insertion)
let hasAccessibility = permissionService.checkAccessibilityPermission()
if !hasAccessibility {
showPermissionAlert(
title: "Accessibility Access Required",
message: "Speech-to-Text needs accessibility access to insert transcribed text into other applications. Please grant permission in System Settings > Privacy & Security > Accessibility.",
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
)
return
}
Comment on lines +193 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The URLs for system settings are hardcoded as string literals in the showPermissionAlert calls. To improve maintainability and avoid "magic strings", it's best practice to extract these into named constants. This makes the code easier to read and update if these URLs ever change.

You could define them in a private enum or struct like this:

private enum SystemSettingsURL {
    static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
    static let accessibility = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
}

Then you can use SystemSettingsURL.microphone and SystemSettingsURL.accessibility in your function calls.


// Create SwiftUI view
let contentView = RecordingModal()
// All permissions granted - show the modal
showRecordingModalWindow()
}

@MainActor
private func showPermissionAlert(title: String, message: String, settingsURL: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Cancel")

let response = alert.runModal()
if response == .alertFirstButtonReturn {
if let url = URL(string: settingsURL) {
NSWorkspace.shared.open(url)
}
}
}

@MainActor
private func showRecordingModalWindow() {
// Create viewModel on MainActor (fixes @State + @Observable + @MainActor race condition)
let viewModel = RecordingViewModel()
recordingViewModel = viewModel

// Create SwiftUI view with the viewModel
let contentView = RecordingModal(viewModel: viewModel)
.onDisappear { [weak self] in
self?.recordingWindow?.close()
self?.recordingWindow = nil
self?.recordingViewModel = nil
}

// Create window for modal
Expand All @@ -160,6 +253,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)

window.contentView = NSHostingView(rootView: contentView)
window.delegate = self // Handle window close via close button
window.isOpaque = false
window.backgroundColor = .clear
window.level = .floating
Expand Down Expand Up @@ -198,6 +292,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

window.title = "Settings"
window.contentView = NSHostingView(rootView: contentView)
window.delegate = self // Handle window close via close button
window.center()
window.makeKeyAndOrderFront(nil)

Expand Down
153 changes: 153 additions & 0 deletions Sources/Views/Components/OnboardingComponents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// OnboardingComponents.swift
// macOS Local Speech-to-Text Application
//
// Helper views for the onboarding flow

import SwiftUI

// MARK: - Feature Row

/// Row displaying a feature with icon and text
struct FeatureRow: View {
let icon: String
let text: String

var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(Color("AmberPrimary", bundle: nil))
Text(text)
}
}
}

// MARK: - Step Instruction

/// Numbered instruction step
struct StepInstruction: View {
let number: Int
let text: String

var body: some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number).")
.fontWeight(.semibold)
Text(text)
}
.font(.callout)
}
}

// MARK: - Demo Instruction

/// Numbered demo instruction with green accent
struct DemoInstruction: View {
let number: Int
let text: String

var body: some View {
HStack(alignment: .top, spacing: 8) {
Text("\(number).")
.fontWeight(.semibold)
.foregroundStyle(.green)
Text(text)
}
}
}

// MARK: - Key Cap View

/// Keyboard key cap visualization
struct KeyCapView: View {
var symbol: String?
var text: String?

var body: some View {
Text(symbol ?? text ?? "")
.font(.title2)
.fontWeight(.medium)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}

// MARK: - Quick Tip

/// Quick tip row with icon and text
struct QuickTip: View {
let icon: String
let text: String

var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.blue)
Text(text)
}
}
}

// MARK: - Permission Status Badge

/// Badge showing permission granted/missing status
struct PermissionStatusBadge: View {
let icon: String
let label: String
let isGranted: Bool

var body: some View {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(isGranted ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.frame(width: 44, height: 44)

Image(systemName: icon)
.font(.system(size: 20))
.foregroundStyle(isGranted ? .green : .orange)
}
.overlay(alignment: .bottomTrailing) {
Image(systemName: isGranted ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(isGranted ? .green : .orange)
.background(Circle().fill(.white).padding(-2))
}

Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}

// MARK: - Previews

#Preview("Feature Row") {
VStack(alignment: .leading, spacing: 12) {
FeatureRow(icon: "lock.shield.fill", text: "100% local processing")
FeatureRow(icon: "cpu.fill", text: "Apple Neural Engine powered")
}
.padding()
}

#Preview("Permission Status Badge") {
HStack(spacing: 16) {
PermissionStatusBadge(icon: "mic.fill", label: "Microphone", isGranted: true)
PermissionStatusBadge(icon: "hand.point.up.left.fill", label: "Accessibility", isGranted: false)
PermissionStatusBadge(icon: "keyboard.fill", label: "Input", isGranted: true)
}
.padding()
}

#Preview("Key Caps") {
HStack(spacing: 8) {
KeyCapView(symbol: "⌘")
Text("+")
KeyCapView(symbol: "⌃")
Text("+")
KeyCapView(text: "Space")
}
.padding()
}
Loading
Loading