├── Assets.xcassets └── AppIcon.appiconset │ ├── 16.png │ ├── 32.png │ ├── 64.png │ ├── 1024.png │ ├── 128.png │ ├── 256.png │ ├── 512.png │ └── Contents.json ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── ismatullamansurov.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ └── ismatullamansurov.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── Whispera.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Whispera.xcscheme ├── scripts ├── ExportOptions.plist ├── ExportOptions-dev.plist ├── release-distribute.template.sh ├── README.md ├── test-keychain-setup.sh ├── check-certificates.sh ├── export-cert-for-ci.sh ├── bump-version.sh ├── setup-keychain.sh └── release-distribute-ci.sh ├── WhisperaTests ├── SimpleTest.swift ├── VersionTests.swift ├── ModelSynchronizationTests.swift ├── SingleInstanceTests.swift └── AudioManagerTests.swift ├── Onboarding ├── FeatureRowView.swift ├── OnboardingProgressView.swift ├── SettingsStepView.swift ├── SettingsRowView.swift ├── PermissionRowView.swift ├── CompleteStepView.swift ├── WelcomeStepView.swift ├── ShortcutsStepView.swift ├── ShortcutOptionsView.swift ├── TestStepView.swift ├── PermissionsStepView.swift └── ModelSelectionView.swift ├── .swift-format ├── WhisperaUITests ├── WhisperaUITestsLaunchTests.swift └── WhisperaUITests.swift ├── Whispera.xctestplan ├── Whispera.entitlements ├── Info.plist ├── What's up? ├── AudioMeterView.swift ├── ListeningWindow.swift └── ListeningView.swift ├── RecordingTimer.swift ├── KeyboardInputSourceManager.swift ├── LiveTranscription ├── LiveTranscriptionView.swift └── DictationView.swift ├── Logger ├── Logger.swift └── LogManager.swift ├── AppVersion.swift ├── .github └── workflows │ ├── build-app.yml │ └── release.yml ├── .gitignore ├── README.md ├── AudioManager ├── AudioLevelMonitor.swift └── AudioEngineController.swift ├── AccessibilityHelper.swift ├── PermissionManager.swift ├── WhisperaUnitTests └── WhisperaUnitTests.swift ├── GlassBeta.swift ├── ContextProvider.swift ├── FileTranscription └── FileTranscriptionProtocols.swift ├── plan.md ├── OnboardingView.swift ├── CLAUDE.md ├── docs └── CICD_SETUP.md ├── Constants.swift └── RecordingIndicator.swift /Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Whispera.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/ismatullamansurov.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapoepsilon/Whispera/HEAD/.swiftpm/xcode/package.xcworkspace/xcuserdata/ismatullamansurov.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/ismatullamansurov.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | MacWhisper.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /scripts/ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | developer-id 7 | destination 8 | export 9 | stripSwiftSymbols 10 | 11 | compileBitcode 12 | 13 | thinning 14 | <none> 15 | teamID 16 | NK28QT38A3 17 | signingStyle 18 | automatic 19 | 20 | -------------------------------------------------------------------------------- /WhisperaTests/SimpleTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class SimpleTest: XCTestCase { 4 | 5 | func testBasicFunctionality() { 6 | // This test should pass - just testing that our test infrastructure works 7 | XCTAssertTrue(true, "Basic test should pass") 8 | } 9 | 10 | func testUserDefaultsAccess() { 11 | // Test that we can access UserDefaults 12 | UserDefaults.standard.set("test", forKey: "testKey") 13 | let value = UserDefaults.standard.string(forKey: "testKey") 14 | XCTAssertEqual(value, "test", "Should be able to read/write UserDefaults") 15 | 16 | // Clean up 17 | UserDefaults.standard.removeObject(forKey: "testKey") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/ExportOptions-dev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | mac-application 7 | destination 8 | export 9 | stripSwiftSymbols 10 | 11 | compileBitcode 12 | 13 | thinning 14 | <none> 15 | teamID 16 | NK28QT38A3 17 | signingStyle 18 | manual 19 | signingCertificate 20 | - 21 | 22 | -------------------------------------------------------------------------------- /Onboarding/FeatureRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureRowView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FeatureRowView: View { 11 | let icon: String 12 | let title: String 13 | let description: String 14 | 15 | var body: some View { 16 | HStack(spacing: 16) { 17 | Image(systemName: icon) 18 | .font(.title2) 19 | .foregroundColor(.blue) 20 | .frame(width: 24) 21 | 22 | VStack(alignment: .leading, spacing: 4) { 23 | Text(title) 24 | .font(.system(.subheadline, design: .rounded, weight: .medium)) 25 | Text(description) 26 | .font(.caption) 27 | .foregroundColor(.secondary) 28 | } 29 | 30 | Spacer() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Onboarding/OnboardingProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingProgressView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingProgressView: View { 11 | let currentStep: Int 12 | let totalSteps: Int 13 | 14 | var body: some View { 15 | VStack(spacing: 8) { 16 | HStack(spacing: 8) { 17 | ForEach(0.. 2 | 3 | 4 | 5 | 6 | com.apple.security.automation.apple-events 7 | 8 | 9 | 10 | com.apple.security.device.microphone 11 | 12 | 13 | 14 | com.apple.security.device.audio-input 15 | 16 | 17 | 18 | com.apple.security.app-sandbox 19 | 20 | 21 | 22 | 23 | 24 | 25 | com.apple.security.cs.allow-jit 26 | 27 | com.apple.security.cs.allow-unsigned-executable-memory 28 | 29 | com.apple.security.cs.allow-dyld-environment-variables 30 | 31 | 32 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0.16 19 | CFBundleVersion 20 | 16 21 | LSMinimumSystemVersion 22 | $(MACOSX_DEPLOYMENT_TARGET) 23 | LSUIElement 24 | 25 | NSAppleEventsUsageDescription 26 | Whispera needs accessibility access to monitor global keyboard shortcuts. 27 | NSMicrophoneUsageDescription 28 | Whispera needs access to your microphone to transcribe audio. 29 | 30 | 31 | -------------------------------------------------------------------------------- /Onboarding/CompleteStepView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompleteStepView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CompleteStepView: View { 11 | var body: some View { 12 | VStack(spacing: 24) { 13 | VStack(spacing: 16) { 14 | Image(systemName: "checkmark.circle.fill") 15 | .font(.system(size: 64)) 16 | .foregroundColor(.green) 17 | 18 | Text("You're All Set!") 19 | .font(.system(.largeTitle, design: .rounded, weight: .bold)) 20 | 21 | Text("Whispera is now configured and ready to use.") 22 | .font(.title3) 23 | .foregroundColor(.secondary) 24 | } 25 | 26 | VStack(spacing: 16) { 27 | Text("Quick Tips:") 28 | .font(.headline) 29 | 30 | VStack(alignment: .leading, spacing: 8) { 31 | Text("• Press your shortcut from anywhere to start recording") 32 | Text("• Click the menu bar icon to see recent transcriptions") 33 | Text("• Visit Settings to customize models and shortcuts") 34 | Text("• Your voice data never leaves your Mac") 35 | } 36 | .font(.subheadline) 37 | .foregroundColor(.secondary) 38 | } 39 | .padding() 40 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"images":[{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} -------------------------------------------------------------------------------- /WhisperaUITests/WhisperaUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhisperaUITests.swift 3 | // WhisperaUITests 4 | // 5 | // Created by Varkhuman Mac on 7/3/25. 6 | // 7 | 8 | import XCTest 9 | 10 | final class WhisperaUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | @MainActor 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | @MainActor 35 | func testLaunchPerformance() throws { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Release Scripts 2 | 3 | ## Setup for Distribution 4 | 5 | 1. **Copy the template:** 6 | ```bash 7 | cp release-distribute.template.sh release-distribute.sh 8 | ``` 9 | 10 | 2. **Edit `release-distribute.sh` with your credentials:** 11 | - Replace `DEVELOPER_ID` with your actual Developer ID Application certificate 12 | - Replace `APPLE_ID` with your Apple ID email 13 | - Replace `APP_SPECIFIC_PASSWORD` with your app-specific password 14 | - Replace `TEAM_ID` with your team identifier 15 | 16 | 3. **Make it executable:** 17 | ```bash 18 | chmod +x release-distribute.sh 19 | ``` 20 | 21 | ## Usage 22 | 23 | Run the complete build and distribution process: 24 | 25 | ```bash 26 | ./scripts/release-distribute.sh 27 | ``` 28 | 29 | This script will: 30 | 1. Clean previous builds 31 | 2. Build and archive the app 32 | 3. Export the app bundle 33 | 4. Sign with proper entitlements 34 | 5. Notarize with Apple 35 | 6. Create a DMG for distribution 36 | 37 | ## Important Notes 38 | 39 | - The `release-distribute.sh` file is excluded from git for security 40 | - Always test the final DMG before distributing 41 | - Make sure your certificates are installed in Keychain 42 | - Verify microphone permissions work in the final build 43 | 44 | ## Files 45 | 46 | - `release-distribute.template.sh` - Template file (tracked in git) 47 | - `release-distribute.sh` - Your actual script with credentials (NOT tracked in git) 48 | - `build-release.sh` - Development build script 49 | - `ExportOptions-dev.plist` - Export configuration -------------------------------------------------------------------------------- /What's up?/AudioMeterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioMeterView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 10/18/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AudioMeterView: View { 11 | let levels: [Float] 12 | var fixedHeight: CGFloat? = nil 13 | 14 | @AppStorage("audioMeterBarWidth") private var barWidth = 3.0 15 | @AppStorage("audioMeterBarSpacing") private var barSpacing = 3.0 16 | @AppStorage("audioMeterMaxHeight") private var maxHeight = 24.0 17 | @AppStorage("audioMeterMinHeight") private var minHeight = 3.0 18 | 19 | private var effectiveMaxHeight: CGFloat { 20 | if let fixedHeight = fixedHeight { 21 | return fixedHeight 22 | } 23 | return maxHeight 24 | } 25 | 26 | var body: some View { 27 | HStack(alignment: .center, spacing: barSpacing) { 28 | ForEach(Array(levels.enumerated()), id: \.offset) { _, level in 29 | RoundedRectangle(cornerRadius: barWidth / 2) 30 | .fill( 31 | LinearGradient( 32 | colors: [ 33 | Color.blue.opacity(0.9), 34 | Color.blue.opacity(0.6), 35 | ], 36 | startPoint: .bottom, 37 | endPoint: .top 38 | ) 39 | ) 40 | .frame( 41 | width: barWidth, 42 | height: calculateBarHeight(for: level) 43 | ) 44 | .animation(.spring(response: 0.15, dampingFraction: 0.6), value: level) 45 | } 46 | } 47 | .frame(height: effectiveMaxHeight) 48 | } 49 | 50 | private func calculateBarHeight(for level: Float) -> CGFloat { 51 | let height = minHeight + (effectiveMaxHeight - minHeight) * CGFloat(level) 52 | return max(minHeight, min(effectiveMaxHeight, height)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /RecordingTimer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | @Observable 5 | final class RecordingTimer { 6 | private(set) var duration: TimeInterval = 0 7 | private(set) var isRunning = false 8 | 9 | @ObservationIgnored 10 | private var timerTask: Task? 11 | 12 | var formatted: String { 13 | let minutes = Int(duration) / 60 14 | let seconds = Int(duration) % 60 15 | let tenths = Int((duration.truncatingRemainder(dividingBy: 1)) * 10) 16 | return String(format: "%02d:%02d.%01d", minutes, seconds, tenths) 17 | } 18 | 19 | func start() { 20 | guard !isRunning else { return } 21 | isRunning = true 22 | duration = 0 23 | 24 | timerTask = Task { 25 | let start = ContinuousClock.now 26 | for await _ in timerStream() { 27 | guard !Task.isCancelled else { break } 28 | duration = (ContinuousClock.now - start).seconds 29 | } 30 | } 31 | } 32 | 33 | func stop() { 34 | isRunning = false 35 | timerTask?.cancel() 36 | timerTask = nil 37 | } 38 | 39 | func reset() { 40 | stop() 41 | duration = 0 42 | } 43 | } 44 | 45 | extension RecordingTimer { 46 | fileprivate func timerStream() -> AsyncStream { 47 | AsyncStream { continuation in 48 | let timer = DispatchSource.makeTimerSource(queue: .main) 49 | timer.schedule(deadline: .now(), repeating: .milliseconds(100)) 50 | timer.setEventHandler { continuation.yield() } 51 | 52 | continuation.onTermination = { _ in timer.cancel() } 53 | timer.resume() 54 | } 55 | } 56 | } 57 | 58 | extension Duration { 59 | fileprivate var seconds: TimeInterval { 60 | Double(components.seconds) + Double(components.attoseconds) / 1e18 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Onboarding/WelcomeStepView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeStepView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | import SwiftUI 8 | 9 | struct WelcomeStepView: View { 10 | var body: some View { 11 | VStack(spacing: 24) { 12 | // App icon and title 13 | VStack(spacing: 16) { 14 | Image(systemName: "waveform.circle.fill") 15 | .font(.system(size: 64)) 16 | .foregroundColor(.blue) 17 | 18 | VStack(spacing: 8) { 19 | Text("Welcome to Whispera") 20 | .font(.system(.largeTitle, design: .rounded, weight: .bold)) 21 | 22 | Text("Whisper-powered voice transcription for macOS") 23 | .font(.title3) 24 | .foregroundColor(.secondary) 25 | } 26 | } 27 | 28 | // Feature highlights 29 | VStack(spacing: 16) { 30 | FeatureRowView( 31 | icon: "mic.fill", 32 | title: "Global Voice Recording", 33 | description: "Record from anywhere with a keyboard shortcut" 34 | ) 35 | 36 | FeatureRowView( 37 | icon: "brain.head.profile", 38 | title: "AI-Powered Transcription", 39 | description: "Local processing with OpenAI Whisper models" 40 | ) 41 | 42 | FeatureRowView( 43 | icon: "lock.shield", 44 | title: "Privacy First", 45 | description: "Everything stays on your Mac - no cloud required" 46 | ) 47 | 48 | FeatureRowView( 49 | icon: "speedometer", 50 | title: "Lightning Fast", 51 | description: "Optimized for Apple Silicon and Intel Macs" 52 | ) 53 | } 54 | 55 | Text("Let's get you set up in just a few steps!") 56 | .font(.headline) 57 | .foregroundColor(.primary) 58 | .padding(.top, 8) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /scripts/test-keychain-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Local test script for keychain setup 4 | set -e 5 | 6 | echo "🧪 Testing keychain setup locally..." 7 | 8 | # Export the certificate with empty password for testing 9 | TEMP_CERT="/tmp/test-whispera-cert.p12" 10 | echo "📦 Exporting certificate for testing..." 11 | security export -t identities -f pkcs12 -k login.keychain -o "$TEMP_CERT" -P "" "Developer ID Application: Ismatulla Mansurov (NK28QT38A3)" 12 | 13 | # Convert to base64 14 | echo "🔄 Converting to base64..." 15 | BASE64_CERT=$(base64 -i "$TEMP_CERT") 16 | 17 | # Set up test environment variables 18 | export DEVELOPER_ID_P12="$BASE64_CERT" 19 | export DEVELOPER_ID_PASSWORD="" # Empty password 20 | export KEYCHAIN_PASSWORD="test-password-123" 21 | 22 | echo "✅ Test environment variables set" 23 | echo " DEVELOPER_ID_P12 length: ${#DEVELOPER_ID_P12} characters" 24 | echo " DEVELOPER_ID_PASSWORD: '${DEVELOPER_ID_PASSWORD}'" 25 | echo " KEYCHAIN_PASSWORD: set" 26 | 27 | # Run the setup script 28 | echo "" 29 | echo "🚀 Running setup-keychain.sh script..." 30 | ./scripts/setup-keychain.sh 31 | 32 | echo "" 33 | echo "🎯 Test completed! Check the output above for any errors." 34 | 35 | # Clean up 36 | rm -f "$TEMP_CERT" 37 | 38 | # Check if keychain was created successfully 39 | if security list-keychains | grep -q "whispera-signing.keychain-db"; then 40 | echo "✅ Keychain created successfully" 41 | 42 | # Show what's in the keychain 43 | echo "📋 Contents of test keychain:" 44 | security find-identity -v whispera-signing.keychain-db 45 | 46 | # Clean up test keychain 47 | echo "🧹 Cleaning up test keychain..." 48 | security delete-keychain whispera-signing.keychain-db 2>/dev/null || true 49 | else 50 | echo "❌ Keychain was not created" 51 | fi -------------------------------------------------------------------------------- /scripts/check-certificates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "🔍 Checking available certificates in your keychain..." 4 | echo "" 5 | 6 | echo "📋 All certificates in login keychain:" 7 | security find-certificate -a login.keychain | grep -A1 -B1 "labl" | grep -E "(labl|Developer|Apple)" 8 | 9 | echo "" 10 | echo "📋 All identities in login keychain:" 11 | security find-identity -v login.keychain 12 | 13 | echo "" 14 | echo "📋 Code signing identities specifically:" 15 | security find-identity -v -p codesigning login.keychain 16 | 17 | echo "" 18 | echo "🎯 Looking for Developer ID certificates..." 19 | security find-identity -v login.keychain | grep -i "developer id" 20 | 21 | echo "" 22 | echo "🏢 Looking for 3rd Party Mac Developer certificates..." 23 | security find-identity -v login.keychain | grep -i "3rd party" 24 | 25 | echo "" 26 | echo "🍎 Looking for Apple Development certificates..." 27 | security find-identity -v login.keychain | grep -i "apple development" 28 | 29 | echo "" 30 | echo "📝 Certificate types you need for different purposes:" 31 | echo " - Developer ID Application: For distributing outside Mac App Store (what we need)" 32 | echo " - Apple Development: For development and testing" 33 | echo " - 3rd Party Mac Developer Application: For Mac App Store submission" 34 | echo " - 3rd Party Mac Developer Installer: For Mac App Store installer packages" 35 | 36 | echo "" 37 | echo "💡 To get a Developer ID Application certificate:" 38 | echo " 1. Go to https://developer.apple.com/account/resources/certificates/list" 39 | echo " 2. Click the + button to create a new certificate" 40 | echo " 3. Select 'Developer ID Application' under 'Production'" 41 | echo " 4. Follow the prompts to create and download the certificate" 42 | echo " 5. Double-click the downloaded certificate to install it in your keychain" -------------------------------------------------------------------------------- /KeyboardInputSourceManager.swift: -------------------------------------------------------------------------------- 1 | import Carbon 2 | import Foundation 3 | 4 | class KeyboardInputSourceManager { 5 | static let shared = KeyboardInputSourceManager() 6 | private let logger = AppLogger.shared.general 7 | 8 | private init() {} 9 | 10 | func getCurrentKeyboardLanguageCode() -> String? { 11 | guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { 12 | logger.info("⚠️ Failed to get current keyboard input source") 13 | return nil 14 | } 15 | 16 | guard let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) else { 17 | logger.info("⚠️ Failed to get input source ID") 18 | return nil 19 | } 20 | 21 | let identifier = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String 22 | logger.info("⌨️ Current keyboard input source identifier: '\(identifier)'") 23 | 24 | let languageCode = Constants.languageCodeFromKeyboardIdentifier(identifier) 25 | 26 | if let code = languageCode { 27 | let languageName = Constants.languageName(for: code) 28 | logger.info("✅ Mapped to language code: '\(code)' (\(languageName))") 29 | } else { 30 | logger.info("⚠️ No mapping found for identifier '\(identifier)' - will fallback to English") 31 | } 32 | 33 | return languageCode 34 | } 35 | 36 | func getLanguageForRecording(autoDetectEnabled: Bool, manualLanguage: String) -> String { 37 | guard autoDetectEnabled else { 38 | logger.info("🔧 Auto-detect disabled, using manual language: \(manualLanguage)") 39 | return manualLanguage 40 | } 41 | 42 | guard let detectedCode = getCurrentKeyboardLanguageCode() else { 43 | logger.info("⚠️ Could not detect keyboard language, falling back to English") 44 | return Constants.defaultLanguageName 45 | } 46 | 47 | let languageName = Constants.languageName(for: detectedCode) 48 | logger.info("🎯 Auto-detected language for recording: \(languageName) (\(detectedCode))") 49 | 50 | return languageName 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /What's up?/ListeningWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListeningWindow.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 10/18/25. 6 | // 7 | 8 | import AppKit 9 | import SwiftUI 10 | 11 | @MainActor 12 | class ListeningWindow: NSWindow { 13 | private let audioManager: AudioManager 14 | private var observationTimer: Timer? 15 | @AppStorage("enableStreaming") private var enableStreaming = false 16 | 17 | init(audioManager: AudioManager) { 18 | self.audioManager = audioManager 19 | 20 | super.init( 21 | contentRect: NSRect(x: 0, y: 0, width: 230, height: 110), 22 | styleMask: [.borderless], 23 | backing: .buffered, 24 | defer: false 25 | ) 26 | 27 | self.level = .floating 28 | self.isOpaque = false 29 | self.backgroundColor = .clear 30 | self.hasShadow = true 31 | self.isMovable = true 32 | self.ignoresMouseEvents = false 33 | self.isMovableByWindowBackground = true 34 | 35 | let hostingView = NSHostingView(rootView: ListeningView(audioManager: audioManager)) 36 | self.contentView = hostingView 37 | 38 | setupObservation() 39 | } 40 | 41 | deinit { 42 | observationTimer?.invalidate() 43 | } 44 | 45 | private func setupObservation() { 46 | observationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in 47 | Task { @MainActor in 48 | guard let self = self else { return } 49 | 50 | let shouldShow = self.audioManager.currentState != .idle && !self.enableStreaming 51 | 52 | if shouldShow && !self.isVisible { 53 | self.positionAtBottomCenter() 54 | self.orderFront(nil) 55 | } else if !shouldShow && self.isVisible { 56 | self.orderOut(nil) 57 | } 58 | } 59 | } 60 | } 61 | 62 | private func positionAtBottomCenter() { 63 | guard let screen = NSScreen.main else { return } 64 | let screenFrame = screen.visibleFrame 65 | let windowX = screenFrame.origin.x + (screenFrame.width - frame.width) / 2 66 | let windowY = screenFrame.origin.y + (screenFrame.height * 0.1) 67 | setFrameOrigin(NSPoint(x: windowX, y: windowY)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /LiveTranscription/LiveTranscriptionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LiveTranscriptionView: View { 4 | @Bindable private var whisperKit = WhisperKitTranscriber.shared 5 | @State private var lastDisplayedText: String = "" 6 | 7 | // Show only the last few words being transcribed 8 | private var latestWords: String { 9 | let currentText = whisperKit.currentText.trimmingCharacters(in: .whitespacesAndNewlines) 10 | 11 | // Filter out WhisperKit's default messages 12 | if currentText.isEmpty || currentText.contains("Waiting for speech") 13 | || currentText.contains("Listening") || currentText.contains("waiting for speech") 14 | || currentText.contains("listening") 15 | { 16 | return "" 17 | } 18 | 19 | // Get the last 6-8 words to show recent context 20 | let words = currentText.components(separatedBy: .whitespacesAndNewlines) 21 | .filter { !$0.isEmpty } 22 | 23 | let maxWords = 8 24 | let recentWords = words.suffix(maxWords) 25 | 26 | return recentWords.joined(separator: " ") 27 | } 28 | 29 | var body: some View { 30 | VStack(spacing: 0) { 31 | if !latestWords.isEmpty { 32 | // Show only the latest words being transcribed 33 | Text(latestWords) 34 | .font(.system(.body, design: .rounded)) 35 | .foregroundColor(.primary) 36 | .multilineTextAlignment(.leading) 37 | .lineLimit(2) // Maximum 2 lines 38 | .padding(.horizontal, 12) 39 | .padding(.vertical, 8) 40 | .animation(.none, value: latestWords) // No animation to prevent rewrites 41 | } else if whisperKit.isTranscribing { 42 | // Minimal listening indicator 43 | HStack(spacing: 6) { 44 | Circle() 45 | .fill(.blue) 46 | .frame(width: 4, height: 4) 47 | .scaleEffect(whisperKit.isTranscribing ? 1.2 : 1.0) 48 | .animation( 49 | .easeInOut(duration: 1.0).repeatForever(autoreverses: true), 50 | value: whisperKit.isTranscribing) 51 | 52 | Text("Listening...") 53 | .font(.system(.caption, design: .rounded)) 54 | .foregroundColor(.secondary) 55 | } 56 | .padding(.horizontal, 12) 57 | .padding(.vertical, 8) 58 | } 59 | } 60 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) 61 | .overlay( 62 | RoundedRectangle(cornerRadius: 8) 63 | .stroke(.primary.opacity(0.1), lineWidth: 1) 64 | ) 65 | .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 2) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /What's up?/ListeningView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListeningView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 10/18/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListeningView: View { 11 | @State private var whisperKit = WhisperKitTranscriber.shared 12 | @AppStorage("listeningViewCornerRadius") private var cornerRadius = 10.0 13 | private let audioManager: AudioManager 14 | 15 | init(audioManager: AudioManager) { 16 | self.audioManager = audioManager 17 | } 18 | 19 | @ViewBuilder 20 | private var contentView: some View { 21 | switch audioManager.currentState { 22 | case .idle: 23 | EmptyView() 24 | case .initializing: 25 | HStack(spacing: 6) { 26 | ProgressView() 27 | .scaleEffect(0.7) 28 | Text("Initializing...") 29 | .font(.system(.caption, design: .rounded)) 30 | .foregroundColor(.secondary) 31 | } 32 | case .transcribing: 33 | Text("Transcribing...") 34 | .font(.system(.caption, design: .rounded)) 35 | .foregroundColor(.secondary) 36 | case .recording: 37 | HStack(spacing: 8) { 38 | AudioMeterView(levels: audioManager.audioLevels) 39 | Button(action: { 40 | audioManager.toggleRecording() 41 | }) { 42 | Image(systemName: "stop.circle.fill") 43 | .font(.system(size: 16)) 44 | .foregroundColor(.secondary) 45 | } 46 | .buttonStyle(.plain) 47 | .help("Stop recording") 48 | } 49 | } 50 | } 51 | 52 | var body: some View { 53 | if #available(macOS 26.0, *) { 54 | contentView 55 | .padding(.horizontal, 14) 56 | .padding(.vertical, 10) 57 | .frame(height: 30) 58 | .fixedSize(horizontal: true, vertical: false) 59 | .glassEffect() 60 | } else { 61 | contentView 62 | .padding(.horizontal, 14) 63 | .padding(.vertical, 10) 64 | .frame(height: 50) 65 | .fixedSize(horizontal: true, vertical: false) 66 | .background( 67 | RoundedRectangle(cornerRadius: cornerRadius) 68 | .fill(.ultraThinMaterial) 69 | ) 70 | .overlay( 71 | RoundedRectangle(cornerRadius: cornerRadius) 72 | .strokeBorder( 73 | LinearGradient( 74 | colors: [ 75 | Color.blue.opacity(0.3), 76 | Color.blue.opacity(0.1), 77 | ], 78 | startPoint: .topLeading, 79 | endPoint: .bottomTrailing 80 | ), 81 | lineWidth: 1 82 | ) 83 | ) 84 | .shadow(color: Color.blue.opacity(0.1), radius: 8, x: 0, y: 2) 85 | .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 1) 86 | } 87 | } 88 | } 89 | 90 | #Preview { 91 | ListeningView(audioManager: AudioManager()) 92 | .frame(width: 200, height: 60) 93 | } 94 | -------------------------------------------------------------------------------- /Logger/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | import Foundation 8 | import SwiftUI 9 | import os.log 10 | 11 | struct ExtendedLogger { 12 | let logger: Logger 13 | let category: String 14 | 15 | func log(_ message: String) { 16 | logger.log("\(message)") 17 | LogManager.shared.writeLog(category: category, level: .default, message: message) 18 | } 19 | 20 | func info(_ message: String) { 21 | logger.info("\(message)") 22 | LogManager.shared.writeLog(category: category, level: .info, message: message) 23 | } 24 | 25 | func debug(_ message: String) { 26 | logger.debug("\(message)") 27 | LogManager.shared.writeLog(category: category, level: .debug, message: message) 28 | } 29 | 30 | func error(_ message: String) { 31 | logger.error("\(message)") 32 | LogManager.shared.writeLog(category: category, level: .error, message: message) 33 | } 34 | 35 | func fault(_ message: String) { 36 | logger.fault("\(message)") 37 | LogManager.shared.writeLog(category: category, level: .fault, message: message) 38 | } 39 | } 40 | 41 | class AppLogger { 42 | static let shared = AppLogger() 43 | private let subsystem = Bundle.main.bundleIdentifier ?? "com.app.whispera" 44 | 45 | lazy var ui = ExtendedLogger(logger: Logger(subsystem: subsystem, category: "UI"), category: "UI") 46 | lazy var network = ExtendedLogger( 47 | logger: Logger(subsystem: subsystem, category: "Network"), category: "Network") 48 | lazy var database = ExtendedLogger( 49 | logger: Logger(subsystem: subsystem, category: "Database"), category: "Database") 50 | lazy var general = ExtendedLogger( 51 | logger: Logger(subsystem: subsystem, category: "General"), category: "General") 52 | lazy var audioManager = ExtendedLogger( 53 | logger: Logger(subsystem: subsystem, category: "AudioManager"), category: "AudioManager") 54 | lazy var transcriber = ExtendedLogger( 55 | logger: Logger(subsystem: subsystem, category: "WhisperTranscriber"), 56 | category: "WhisperTranscriber") 57 | lazy var liveTranscriber = ExtendedLogger( 58 | logger: Logger(subsystem: subsystem, category: "WhisperLiveTranscriber"), 59 | category: "WhisperLiveTranscriber") 60 | lazy var fileTranscriber = ExtendedLogger( 61 | logger: Logger(subsystem: subsystem, category: "WhisperFileTranscriber"), 62 | category: "WhisperFileTranscriber") 63 | lazy var youtubeTranscriber = ExtendedLogger( 64 | logger: Logger(subsystem: subsystem, category: "WhisperYouTubeTranscriber"), 65 | category: "WhisperYouTubeTranscriber") 66 | 67 | private init() { 68 | let defaults = UserDefaults.standard 69 | if defaults.object(forKey: "enableExtendedLogging") == nil { 70 | defaults.set(true, forKey: "enableExtendedLogging") 71 | } 72 | if defaults.object(forKey: "enableDebugLogging") == nil { 73 | defaults.set(false, forKey: "enableDebugLogging") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /AppVersion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Bundle Extension for Version Access 4 | 5 | extension Bundle { 6 | /// App version number (CFBundleShortVersionString) 7 | var releaseVersionNumber: String? { 8 | return infoDictionary?["CFBundleShortVersionString"] as? String 9 | } 10 | 11 | /// Build number (CFBundleVersion) 12 | var buildVersionNumber: String? { 13 | return infoDictionary?["CFBundleVersion"] as? String 14 | } 15 | } 16 | 17 | /// Represents a semantic version (major.minor.patch) 18 | struct AppVersion: Equatable, Comparable, Codable { 19 | let major: Int 20 | let minor: Int 21 | let patch: Int 22 | let versionString: String 23 | 24 | init(_ versionString: String) { 25 | self.versionString = versionString 26 | 27 | let components = versionString.split(separator: ".").compactMap { Int($0) } 28 | self.major = components.count > 0 ? components[0] : 0 29 | self.minor = components.count > 1 ? components[1] : 0 30 | self.patch = components.count > 2 ? components[2] : 0 31 | } 32 | 33 | /// Current app version from bundle (dynamic) 34 | static var current: AppVersion { 35 | let version = Bundle.main.releaseVersionNumber ?? "1.0.0" 36 | return AppVersion(version) 37 | } 38 | 39 | /// Current build number from bundle 40 | static var currentBuild: String { 41 | return Bundle.main.buildVersionNumber ?? "1" 42 | } 43 | 44 | /// Formatted version string for display 45 | var displayString: String { 46 | return "v\(versionString)" 47 | } 48 | 49 | /// Check if this version is newer than another version string 50 | func isNewerThan(_ other: String) -> Bool { 51 | return self > AppVersion(other) 52 | } 53 | 54 | // MARK: - Comparable 55 | 56 | static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { 57 | if lhs.major != rhs.major { 58 | return lhs.major < rhs.major 59 | } 60 | if lhs.minor != rhs.minor { 61 | return lhs.minor < rhs.minor 62 | } 63 | return lhs.patch < rhs.patch 64 | } 65 | 66 | static func == (lhs: AppVersion, rhs: AppVersion) -> Bool { 67 | return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch 68 | } 69 | } 70 | 71 | // MARK: - Version Constants 72 | 73 | extension AppVersion { 74 | /// Centralized constants for app configuration 75 | struct Constants { 76 | /// Minimum supported macOS version 77 | static let minimumMacOS = "13.0" 78 | 79 | /// GitHub repository for updates 80 | static let githubRepo = "sapoepsilon/Whispera" 81 | 82 | /// Update check URL 83 | static let updateURL = "https://api.github.com/repos/\(githubRepo)/releases/latest" 84 | 85 | /// Current app version string (dynamically retrieved) 86 | static var currentVersionString: String { 87 | return AppVersion.current.versionString 88 | } 89 | 90 | /// Current build number (dynamically retrieved) 91 | static var currentBuildString: String { 92 | return AppVersion.currentBuild 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Whispera.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "e90b8a11a215aa5c3ec86d8e6d5e386df4fb911eb6af7f86f86b81e22d357f12", 3 | "pins" : [ 4 | { 5 | "identity" : "jinja", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/johnmai-dev/Jinja", 8 | "state" : { 9 | "revision" : "5c0a87846dfd36ca6621795ad2f09fdaab82b739", 10 | "version" : "1.3.0" 11 | } 12 | }, 13 | { 14 | "identity" : "networkimage", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/gonzalezreal/NetworkImage", 17 | "state" : { 18 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 19 | "version" : "6.0.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-argument-parser", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-argument-parser.git", 26 | "state" : { 27 | "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", 28 | "version" : "1.6.2" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-cmark", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-cmark", 35 | "state" : { 36 | "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", 37 | "version" : "0.7.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-collections", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-collections.git", 44 | "state" : { 45 | "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", 46 | "version" : "1.3.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-markdown-ui", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 53 | "state" : { 54 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 55 | "version" : "2.4.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-transformers", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/huggingface/swift-transformers.git", 62 | "state" : { 63 | "revision" : "8a83416cc00ab07a5de9991e6ad817a9b8588d20", 64 | "version" : "0.1.15" 65 | } 66 | }, 67 | { 68 | "identity" : "whisperkit", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/argmaxinc/WhisperKit", 71 | "state" : { 72 | "branch" : "main", 73 | "revision" : "4ef384ec769b6fd51b49c0e8e74e8d8083e44926" 74 | } 75 | }, 76 | { 77 | "identity" : "youtubekit", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/alexeichhorn/YouTubeKit", 80 | "state" : { 81 | "revision" : "c7d4ff4270fb40d354dede803abc0a0616b94eb2", 82 | "version" : "0.3.2" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /scripts/export-cert-for-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Certificate Export Helper for Whispera CI 4 | # Helps export your Developer ID certificate and set up GitHub secrets 5 | 6 | set -e 7 | 8 | CERT_NAME="Developer ID Application: Ismatulla Mansurov (NK28QT38A3)" 9 | APPLE_ID="sapoepsilon98@yandex.com" 10 | TEAM_ID="NK28QT38A3" 11 | 12 | echo "🔍 Searching for Developer ID certificate in keychain..." 13 | 14 | # Check if certificate exists in keychain 15 | if ! security find-certificate -c "$CERT_NAME" >/dev/null 2>&1; then 16 | echo "❌ Certificate not found in keychain" 17 | echo "🔍 Available certificates:" 18 | security find-certificate -p | openssl x509 -noout -subject 2>/dev/null || echo "No certificates found" 19 | exit 1 20 | fi 21 | 22 | echo "✅ Found certificate: $CERT_NAME" 23 | 24 | # Create temporary directory 25 | TEMP_DIR=$(mktemp -d) 26 | CERT_FILE="$TEMP_DIR/whispera-cert.p12" 27 | 28 | echo "📦 Exporting certificate..." 29 | echo "🔑 You'll be prompted for:" 30 | echo " 1. Keychain password (if locked)" 31 | echo " 2. New password for the p12 file (remember this!)" 32 | 33 | # Export the certificate and private key 34 | security export -t identities -f pkcs12 -o "$CERT_FILE" -k login.keychain 35 | 36 | if [ ! -f "$CERT_FILE" ]; then 37 | echo "❌ Failed to export certificate" 38 | rm -rf "$TEMP_DIR" 39 | exit 1 40 | fi 41 | 42 | echo "✅ Certificate exported successfully" 43 | 44 | # Convert to base64 45 | echo "🔄 Converting to base64..." 46 | BASE64_CERT=$(base64 -i "$CERT_FILE") 47 | 48 | if [ -z "$BASE64_CERT" ]; then 49 | echo "❌ Failed to encode certificate" 50 | rm -rf "$TEMP_DIR" 51 | exit 1 52 | fi 53 | 54 | echo "✅ Certificate encoded (${#BASE64_CERT} characters)" 55 | 56 | # Prompt for certificate password 57 | echo "" 58 | read -s -p "🔑 Enter the password you used for the p12 export: " CERT_PASSWORD 59 | echo "" 60 | 61 | if [ -z "$CERT_PASSWORD" ]; then 62 | echo "❌ Password cannot be empty" 63 | rm -rf "$TEMP_DIR" 64 | exit 1 65 | fi 66 | 67 | # Generate a secure keychain password 68 | KEYCHAIN_PASSWORD=$(openssl rand -base64 32) 69 | 70 | echo "🚀 Setting up GitHub secrets..." 71 | 72 | # Set all the secrets 73 | gh secret set DEVELOPER_ID_P12 --body "$BASE64_CERT" 74 | gh secret set DEVELOPER_ID_PASSWORD --body "$CERT_PASSWORD" 75 | gh secret set KEYCHAIN_PASSWORD --body "$KEYCHAIN_PASSWORD" 76 | gh secret set APPLE_ID --body "$APPLE_ID" 77 | gh secret set TEAM_ID --body "$TEAM_ID" 78 | 79 | echo "✅ GitHub secrets updated!" 80 | 81 | # Clean up 82 | rm -rf "$TEMP_DIR" 83 | 84 | echo "" 85 | echo "📋 Summary of configured secrets:" 86 | gh secret list 87 | 88 | echo "" 89 | echo "🎯 Next steps:" 90 | echo "1. Set APP_SPECIFIC_PASSWORD secret manually:" 91 | echo " - Go to https://appleid.apple.com" 92 | echo " - Generate an app-specific password" 93 | echo " - Run: gh secret set APP_SPECIFIC_PASSWORD --body 'your-app-password'" 94 | echo "" 95 | echo "2. Test the release workflow:" 96 | echo " git tag v1.0.9 && git push origin v1.0.9" -------------------------------------------------------------------------------- /Onboarding/ShortcutsStepView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShortcutsStepView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | import SwiftUI 8 | 9 | struct ShortcutStepView: View { 10 | @Binding var customShortcut: String 11 | @Binding var showingShortcutCapture: Bool 12 | @State private var isCapturing = false 13 | @State private var fileSelectionShortcut = "⌃F" 14 | @State private var showingFileShortcutCapture = false 15 | 16 | var body: some View { 17 | VStack(spacing: 24) { 18 | VStack(spacing: 16) { 19 | Image(systemName: "keyboard") 20 | .font(.system(size: 48)) 21 | .foregroundColor(.purple) 22 | 23 | Text("Set Your Shortcuts") 24 | .font(.system(.title, design: .rounded, weight: .semibold)) 25 | 26 | Text( 27 | "Choose keyboard shortcuts to quickly start recording or transcribe files from anywhere." 28 | ) 29 | .font(.body) 30 | .foregroundColor(.secondary) 31 | .multilineTextAlignment(.center) 32 | } 33 | 34 | VStack(spacing: 20) { 35 | // Recording shortcut 36 | VStack(spacing: 12) { 37 | Text("Recording shortcut:") 38 | .font(.subheadline) 39 | .foregroundColor(.secondary) 40 | 41 | HStack { 42 | Text(customShortcut) 43 | .font(.system(.title2, design: .monospaced, weight: .semibold)) 44 | .padding(.horizontal, 16) 45 | .padding(.vertical, 8) 46 | .background(.purple.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 47 | 48 | Button("Change") { 49 | showShortcutOptions() 50 | } 51 | .buttonStyle(SecondaryButtonStyle()) 52 | } 53 | 54 | if showingShortcutCapture { 55 | ShortcutOptionsView( 56 | customShortcut: $customShortcut, showingOptions: $showingShortcutCapture) 57 | } 58 | } 59 | 60 | // File selection shortcut 61 | VStack(spacing: 12) { 62 | Text("File transcription shortcut:") 63 | .font(.subheadline) 64 | .foregroundColor(.secondary) 65 | 66 | HStack { 67 | Text(fileSelectionShortcut) 68 | .font(.system(.title2, design: .monospaced, weight: .semibold)) 69 | .padding(.horizontal, 16) 70 | .padding(.vertical, 8) 71 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 72 | 73 | Button("Change") { 74 | showFileShortcutOptions() 75 | } 76 | .buttonStyle(SecondaryButtonStyle()) 77 | } 78 | 79 | if showingFileShortcutCapture { 80 | ShortcutOptionsView( 81 | customShortcut: $fileSelectionShortcut, 82 | showingOptions: $showingFileShortcutCapture) 83 | } 84 | } 85 | 86 | Text("You can change these later in Settings") 87 | .font(.caption) 88 | .foregroundColor(.secondary) 89 | } 90 | } 91 | } 92 | 93 | private func showShortcutOptions() { 94 | showingShortcutCapture.toggle() 95 | } 96 | 97 | private func showFileShortcutOptions() { 98 | showingFileShortcutCapture.toggle() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /WhisperaTests/VersionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Whispera 4 | 5 | final class VersionTests: XCTestCase { 6 | 7 | // MARK: - Version Comparison Tests 8 | 9 | func testVersionComparison() { 10 | // Test semantic version comparison 11 | XCTAssertTrue(AppVersion("1.0.1").isNewerThan("1.0.0")) 12 | XCTAssertTrue(AppVersion("1.1.0").isNewerThan("1.0.9")) 13 | XCTAssertTrue(AppVersion("2.0.0").isNewerThan("1.9.9")) 14 | XCTAssertFalse(AppVersion("1.0.0").isNewerThan("1.0.0")) 15 | XCTAssertFalse(AppVersion("1.0.0").isNewerThan("1.0.1")) 16 | } 17 | 18 | func testVersionEquality() { 19 | // Test version equality 20 | XCTAssertEqual(AppVersion("1.0.0"), AppVersion("1.0.0")) 21 | XCTAssertNotEqual(AppVersion("1.0.0"), AppVersion("1.0.1")) 22 | } 23 | 24 | func testVersionParsing() { 25 | // Test version string parsing 26 | let version = AppVersion("1.2.3") 27 | XCTAssertEqual(version.major, 1) 28 | XCTAssertEqual(version.minor, 2) 29 | XCTAssertEqual(version.patch, 3) 30 | } 31 | 32 | func testInvalidVersionHandling() { 33 | // Test handling of invalid version strings 34 | let invalidVersion = AppVersion("invalid") 35 | XCTAssertEqual(invalidVersion.major, 0) 36 | XCTAssertEqual(invalidVersion.minor, 0) 37 | XCTAssertEqual(invalidVersion.patch, 0) 38 | } 39 | 40 | func testCurrentAppVersion() { 41 | // Test that current app version is accessible 42 | let currentVersion = AppVersion.current 43 | XCTAssertNotNil(currentVersion) 44 | XCTAssertFalse(currentVersion.versionString.isEmpty) 45 | } 46 | 47 | func testVersionFromBundle() { 48 | // Test reading version from bundle 49 | let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 50 | XCTAssertNotNil(bundleVersion) 51 | 52 | let appVersion = AppVersion.current 53 | XCTAssertEqual(appVersion.versionString, bundleVersion) 54 | } 55 | } 56 | 57 | // MARK: - Version Model (to be implemented) 58 | 59 | struct AppVersion: Equatable, Comparable { 60 | let major: Int 61 | let minor: Int 62 | let patch: Int 63 | let versionString: String 64 | 65 | init(_ versionString: String) { 66 | self.versionString = versionString 67 | 68 | let components = versionString.split(separator: ".").compactMap { Int($0) } 69 | self.major = components.count > 0 ? components[0] : 0 70 | self.minor = components.count > 1 ? components[1] : 0 71 | self.patch = components.count > 2 ? components[2] : 0 72 | } 73 | 74 | static var current: AppVersion { 75 | let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" 76 | return AppVersion(version) 77 | } 78 | 79 | func isNewerThan(_ other: String) -> Bool { 80 | return self > AppVersion(other) 81 | } 82 | 83 | static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { 84 | if lhs.major != rhs.major { 85 | return lhs.major < rhs.major 86 | } 87 | if lhs.minor != rhs.minor { 88 | return lhs.minor < rhs.minor 89 | } 90 | return lhs.patch < rhs.patch 91 | } 92 | 93 | static func == (lhs: AppVersion, rhs: AppVersion) -> Bool { 94 | return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/build-app.yml: -------------------------------------------------------------------------------- 1 | name: Build App 2 | 3 | on: 4 | pull_request: 5 | branches: [main, develop] 6 | push: 7 | branches: [main, develop] 8 | workflow_dispatch: 9 | 10 | env: 11 | APP_NAME: "Whispera" 12 | SCHEME_NAME: "Whispera" 13 | 14 | jobs: 15 | build-app: 16 | runs-on: macos-15 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Xcode 23 | uses: maxim-lobanov/setup-xcode@v1 24 | with: 25 | xcode-version: latest-stable 26 | 27 | - name: Cache Swift Package Manager 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | .build 32 | ~/Library/Developer/Xcode/DerivedData 33 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 34 | restore-keys: | 35 | ${{ runner.os }}-spm- 36 | 37 | - name: Resolve Swift Package Dependencies 38 | run: | 39 | xcodebuild -resolvePackageDependencies -project "${APP_NAME}.xcodeproj" -scheme "${SCHEME_NAME}" 40 | 41 | - name: Build app (unsigned for CI validation) 42 | run: | 43 | echo "🔨 Building Whispera app..." 44 | 45 | # Build using scheme (includes test targets but they won't be run) 46 | # Using destination platform to avoid C compilation issues 47 | xcodebuild build \ 48 | -project "${APP_NAME}.xcodeproj" \ 49 | -scheme "${SCHEME_NAME}" \ 50 | -configuration Debug \ 51 | -destination "platform=macOS,arch=arm64" \ 52 | CODE_SIGN_IDENTITY="-" \ 53 | CODE_SIGNING_REQUIRED=NO 54 | 55 | # Check if the main app binary was created 56 | if [ -d "/Users/runner/Library/Developer/Xcode/DerivedData/${APP_NAME}-"*/Build/Products/Debug/"${APP_NAME}.app" ]; then 57 | echo "✅ Main app built successfully" 58 | ls -la /Users/runner/Library/Developer/Xcode/DerivedData/${APP_NAME}-*/Build/Products/Debug/"${APP_NAME}.app" 59 | else 60 | echo "❌ Build failed - app bundle not found" 61 | exit 1 62 | fi 63 | 64 | - name: Check for SwiftUI deprecations 65 | run: | 66 | # Look for deprecated SwiftUI APIs 67 | if grep -r "\.navigationBarTitle\|\.navigationBarItems" --include="*.swift" . ; then 68 | echo "⚠️ Found deprecated SwiftUI APIs" 69 | fi 70 | 71 | - name: Analyze code size 72 | run: | 73 | # Basic code metrics 74 | echo "📊 Code Statistics:" 75 | find . -name "*.swift" -not -path "./build/*" -not -path "./DerivedData/*" | wc -l | xargs echo "Swift files:" 76 | find . -name "*.swift" -not -path "./build/*" -not -path "./DerivedData/*" -exec wc -l {} + | tail -1 | xargs echo "Total lines:" 77 | 78 | - name: Upload test results 79 | if: always() 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: test-results 83 | path: | 84 | build/DerivedData/Logs/Test/ 85 | build/ 86 | retention-days: 3 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the pods 60 | # dependencies. 61 | # Pods/* 62 | 63 | # Carthage 64 | # 65 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 66 | # Carthage/Checkouts 67 | 68 | Carthage/Build/ 69 | 70 | # Accio dependency management 71 | Dependencies/ 72 | .accio/ 73 | 74 | # fastlane 75 | # 76 | # It is recommended to not store the certificates and profiles in the repository and instead 77 | # store them in a keychain. Profiles and certificates are stored in your Apple Developer account. 78 | # 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots/**/*.png 82 | fastlane/test_output 83 | 84 | # Code Injection 85 | # 86 | # After new code Injection tools there's a generated folder /iOSInjectionProject 87 | # https://github.com/johnno1962/injectionforxcode 88 | 89 | iOSInjectionProject/ 90 | 91 | # macOS 92 | .DS_Store 93 | .DS_Store? 94 | ._* 95 | .Spotlight-V100 96 | .Trashes 97 | ehthumbs.db 98 | Thumbs.db 99 | 100 | # Environment variables 101 | .env 102 | .env.local 103 | .env.development.local 104 | .env.test.local 105 | .env.production.local 106 | 107 | # Personal information 108 | **/UserInterfaceState.xcuserstate 109 | **/xcschememanagement.plist 110 | 111 | # Temporary files 112 | *.tmp 113 | *.temp 114 | *.stack.js 115 | 116 | # Distribution and release scripts with credentials 117 | scripts/release-distribute.sh 118 | dist/ 119 | *.dmg 120 | *.zip 121 | 122 | # Code signing and distribution 123 | *.mobileprovision 124 | *.p12 125 | *.cer 126 | 127 | # Claude 128 | .mcp.json 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whispera 2 | 3 | A native macOS app that replaces the built-in dictation with OpenAI's Whisper for superior transcription accuracy. Transcribe speech, local files, YouTube videos, and network streams - all processed locally on your Neural Engine. 4 | 5 | 6 | ### [⬇️ Download Latest Release](https://github.com/sapoepsilon/Whispera/releases/latest) 7 | 8 | [](https://github.com/sapoepsilon/Whispera/releases/latest) 9 | 10 | 11 | 12 | ## Demos 13 | 14 | 15 | 16 | Speech to Text Field 17 | File/URL Transcription with Timestamps 18 | 19 | 20 | 21 | 22 | Your browser does not support the video tag. 23 | 24 | 25 | 26 | 27 | Your browser does not support the video tag. 28 | 29 | 30 | 31 | 32 | 33 | ## Features 34 | 35 | - **Live transcription** (beta) 36 | - **Speech-to-text** - Replaces macOS native dictation with WhisperKit (OpenAI's Whisper model on Neural Engine) for better accuracy 37 | - **File transcription** - Audio and video files 38 | - **Network media transcription** - Stream video/music URLs 39 | - **YouTube transcription** 40 | 41 | All processing runs locally. Internet required only for initial model download. 42 | ## Roadmap 43 | 44 | - [x] Multi-language support beyond English 45 | - **PR**: https://github.com/sapoepsilon/Whispera/pull/2 46 | - **Release**: https://github.com/sapoepsilon/Whispera/releases/tag/v1.0.3 47 | - [x] Real-time translation capabilities 48 | - **PR**: https://github.com/sapoepsilon/Whispera/pull/17 49 | - **Release**: https://github.com/sapoepsilon/Whispera/releases/tag/v1.0.18 50 | - [ ] Additional customization options 51 | 52 | ## Usage 53 | 54 | Simply use your configured global shortcut to start transcribing with Whisper instead of the default macOS dictation. 55 | 56 | ## Known Issues 57 | 58 | - The app does not work with Intel mac(see [Issue 15](https://github.com/sapoepsilon/whispera/issues/15) 59 | - Auto install does not work, after an app has been downloaded, please manually drag and drop the app to you `/Application` folder 60 | - There is a weird issue with app quiting unexpectedly if you get that please report it here: [Issue 21](https://github.com/sapoepsilon/whispera/issues/21) 61 | ## Requirements 62 | 63 | - macOS 13.0 or later 64 | - Apple Silicon 65 | - We are working on support for Intel Mac 66 | 67 | ## Credits 68 | 69 | Built with: 70 | - [WhisperKit](https://github.com/argmaxinc/WhisperKit) - On-device Whisper transcription for Apple Silicon 71 | - [YouTubeKit](https://github.com/alexeichhorn/YouTubeKit) - YouTube content extraction 72 | - [swift-markdown-ui](https://github.com/gonzalezreal/swift-markdown-ui) 73 | 74 | 75 | Thanks to these projects for making privacy-focused, local transcription a reality. 76 | 77 | ## License 78 | 79 | MIT License 80 | -------------------------------------------------------------------------------- /LiveTranscription/DictationView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DictationView: View { 4 | @State private var whisperKit = WhisperKitTranscriber.shared 5 | private let audioManager: AudioManager 6 | 7 | // Live transcription customization settings 8 | @AppStorage("liveTranscriptionMaxWords") private var maxWordsToShow = 5 9 | @AppStorage("liveTranscriptionCornerRadius") private var cornerRadius = 10.0 10 | @AppStorage("liveTranscriptionShowEllipsis") private var showEllipsis = true 11 | 12 | init(audioManager: AudioManager) { 13 | self.audioManager = audioManager 14 | } 15 | 16 | private var displayWords: [(text: String, isLast: Bool)] { 17 | let words = whisperKit.stableDisplayText 18 | .split(separator: " ") 19 | .map(String.init) 20 | 21 | guard !words.isEmpty else { return [] } 22 | 23 | // Take only the last N words 24 | let wordsToShow = words.suffix(maxWordsToShow) 25 | let startIndex = words.count - wordsToShow.count 26 | 27 | return wordsToShow.enumerated().map { index, word in 28 | (text: word, isLast: index == wordsToShow.count - 1) 29 | } 30 | } 31 | 32 | var body: some View { 33 | VStack(spacing: 0) { 34 | if !whisperKit.stableDisplayText.isEmpty { 35 | HStack(spacing: 4) { 36 | if showEllipsis 37 | && whisperKit.stableDisplayText.split(separator: " ").count > maxWordsToShow 38 | { 39 | Text("...") 40 | .font(.system(.body, design: .rounded)) 41 | .foregroundColor(Color.secondary.opacity(0.6)) 42 | .padding(.trailing, 2) 43 | } 44 | 45 | ForEach(Array(displayWords.enumerated()), id: \.offset) { _, wordInfo in 46 | Text(wordInfo.text) 47 | .font(.system(wordInfo.isLast ? .title3 : .body, design: .rounded)) 48 | .foregroundColor(wordInfo.isLast ? Color.blue : Color.primary.opacity(0.8)) 49 | .fontWeight(wordInfo.isLast ? .semibold : .regular) 50 | .animation(.easeInOut(duration: 0.15), value: wordInfo.isLast) 51 | } 52 | } 53 | .padding(.horizontal, 14) 54 | .padding(.vertical, 10) 55 | .transition(.opacity.combined(with: .scale(scale: 0.95))) 56 | } else if whisperKit.isTranscribing { 57 | ListeningView(audioManager: audioManager) 58 | } 59 | } 60 | .fixedSize() 61 | .background( 62 | RoundedRectangle(cornerRadius: cornerRadius) 63 | .fill(.ultraThinMaterial) 64 | .overlay( 65 | RoundedRectangle(cornerRadius: cornerRadius) 66 | .fill( 67 | LinearGradient( 68 | colors: [ 69 | Color.blue.opacity(0.05), 70 | Color.blue.opacity(0.02), 71 | ], 72 | startPoint: .topLeading, 73 | endPoint: .bottomTrailing 74 | ) 75 | ) 76 | ) 77 | ) 78 | .overlay( 79 | RoundedRectangle(cornerRadius: cornerRadius) 80 | .strokeBorder( 81 | LinearGradient( 82 | colors: [ 83 | Color.blue.opacity(0.3), 84 | Color.blue.opacity(0.1), 85 | ], 86 | startPoint: .topLeading, 87 | endPoint: .bottomTrailing 88 | ), 89 | lineWidth: 1 90 | ) 91 | ) 92 | .shadow(color: Color.blue.opacity(0.1), radius: 8, x: 0, y: 2) 93 | .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 1) 94 | } 95 | } 96 | 97 | #Preview { 98 | DictationView(audioManager: AudioManager()) 99 | .frame(width: 300) 100 | .padding() 101 | } 102 | -------------------------------------------------------------------------------- /AudioManager/AudioLevelMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | @Observable 5 | final class AudioLevelMonitor { 6 | private(set) var levels: [Float] 7 | private(set) var peakLevel: Float = 0 8 | private(set) var averageLevel: Float = 0 9 | private(set) var isSilent: Bool = true 10 | private(set) var consecutiveSilentFrames: Int = 0 11 | 12 | private let bandCount: Int 13 | private let silenceThreshold: Float = 0.001 14 | private var recentPeaks: [Float] = [] 15 | private let peakHistorySize = 60 16 | private var adaptiveGain: Float = 5.0 17 | 18 | init(bandCount: Int = 7) { 19 | self.bandCount = bandCount 20 | self.levels = Array(repeating: 0, count: bandCount) 21 | } 22 | 23 | var hasAudioActivity: Bool { 24 | !isSilent && peakLevel > silenceThreshold 25 | } 26 | 27 | var microphoneStatus: MicrophoneStatus { 28 | if consecutiveSilentFrames > 50 { 29 | return .blocked 30 | } else if isSilent { 31 | return .silent 32 | } else { 33 | return .active 34 | } 35 | } 36 | 37 | func update(from samples: [Float]) { 38 | guard !samples.isEmpty else { 39 | markSilent() 40 | return 41 | } 42 | 43 | let samplesPerBand = max(1, samples.count / bandCount) 44 | 45 | var newLevels: [Float] = [] 46 | var maxLevel: Float = 0 47 | var sum: Float = 0 48 | 49 | for i in 0.. peakHistorySize { 84 | recentPeaks.removeFirst() 85 | } 86 | 87 | guard recentPeaks.count >= 10 else { return } 88 | 89 | let sortedPeaks = recentPeaks.sorted() 90 | let percentile90 = sortedPeaks[Int(Float(sortedPeaks.count) * 0.9)] 91 | 92 | if percentile90 > 0.001 { 93 | let targetGain = 0.7 / percentile90 94 | let clampedGain = max(2.0, min(20.0, targetGain)) 95 | adaptiveGain = adaptiveGain * 0.95 + clampedGain * 0.05 96 | } 97 | } 98 | 99 | func reset() { 100 | levels = Array(repeating: 0, count: bandCount) 101 | peakLevel = 0 102 | averageLevel = 0 103 | isSilent = true 104 | consecutiveSilentFrames = 0 105 | recentPeaks.removeAll() 106 | adaptiveGain = 5.0 107 | } 108 | 109 | private func markSilent() { 110 | consecutiveSilentFrames += 1 111 | isSilent = true 112 | peakLevel = 0 113 | averageLevel = 0 114 | } 115 | } 116 | 117 | enum MicrophoneStatus: String, CustomStringConvertible { 118 | case active = "Active" 119 | case silent = "Silent" 120 | case blocked = "Blocked (no audio)" 121 | 122 | var description: String { rawValue } 123 | } 124 | -------------------------------------------------------------------------------- /AccessibilityHelper.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import ApplicationServices 3 | 4 | /// Shared utility for accessibility-based caret position detection 5 | @MainActor 6 | class AccessibilityHelper { 7 | static var caretPosition: NSPoint? { 8 | didSet { 9 | onCaretChange?(caretPosition) 10 | } 11 | } 12 | static var onCaretChange: ((NSPoint?) -> Void)? 13 | 14 | /// Get the current caret position in screen coordinates 15 | static func getCaretPosition() -> NSPoint? { 16 | guard AXIsProcessTrusted() else { 17 | print("❌ App doesn't have accessibility permissions") 18 | return nil 19 | } 20 | return tryDirectFocusedElementMethod() 21 | } 22 | 23 | /// Request accessibility permissions if not already granted 24 | static func requestAccessibilityPermissions() { 25 | let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] 26 | let trusted = AXIsProcessTrustedWithOptions(options as CFDictionary) 27 | print("🔐 Accessibility permissions: \(trusted)") 28 | } 29 | 30 | /// Check if accessibility permissions are granted 31 | static func hasAccessibilityPermissions() -> Bool { 32 | return AXIsProcessTrusted() 33 | } 34 | 35 | // MARK: - Private Methods 36 | 37 | private static func tryDirectFocusedElementMethod() -> NSPoint? { 38 | let system = AXUIElementCreateSystemWide() 39 | var application: CFTypeRef? 40 | var focusedElement: CFTypeRef? 41 | 42 | // Step 1: Find the currently focused application 43 | guard 44 | AXUIElementCopyAttributeValue( 45 | system, kAXFocusedApplicationAttribute as CFString, &application) == .success 46 | else { 47 | print("❌ Could not get focused application") 48 | return nil 49 | } 50 | 51 | // Step 2: Find the currently focused UI Element in that application 52 | guard 53 | AXUIElementCopyAttributeValue( 54 | application! as! AXUIElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) 55 | == .success 56 | else { 57 | print("❌ Could not get focused UI element") 58 | return nil 59 | } 60 | 61 | return getCaretFromElement(focusedElement! as! AXUIElement) 62 | } 63 | 64 | private static func getCaretFromElement(_ element: AXUIElement) -> NSPoint? { 65 | // Check if element has selection range attribute 66 | var rangeValueRef: CFTypeRef? 67 | guard 68 | AXUIElementCopyAttributeValue( 69 | element, kAXSelectedTextRangeAttribute as CFString, &rangeValueRef) == .success 70 | else { 71 | return nil 72 | } 73 | 74 | let rangeValue = rangeValueRef! as! AXValue 75 | var cfRange = CFRange() 76 | guard AXValueGetValue(rangeValue, .cfRange, &cfRange) else { 77 | return nil 78 | } 79 | 80 | // Get screen bounds for the cursor position 81 | var bounds: CFTypeRef? 82 | guard 83 | AXUIElementCopyParameterizedAttributeValue( 84 | element, kAXBoundsForRangeParameterizedAttribute as CFString, rangeValue, &bounds) 85 | == .success 86 | else { 87 | return nil 88 | } 89 | 90 | var screenRect = CGRect.zero 91 | guard AXValueGetValue(bounds! as! AXValue, .cgRect, &screenRect) else { 92 | return nil 93 | } 94 | caretPosition = carbonToCocoa( 95 | carbonPoint: NSPoint(x: screenRect.origin.x, y: screenRect.origin.y)) 96 | return caretPosition 97 | } 98 | 99 | private static func carbonToCocoa(carbonPoint: NSPoint) -> NSPoint { 100 | // Convert Carbon screen coordinates to Cocoa screen coordinates 101 | guard let mainScreen = NSScreen.main else { 102 | return carbonPoint 103 | } 104 | let screenHeight = mainScreen.frame.size.height 105 | return NSPoint(x: carbonPoint.x, y: screenHeight - carbonPoint.y) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Whispera Version Bumping Script 4 | # Updates version numbers in Xcode project files 5 | 6 | set -e 7 | 8 | VERSION="$1" 9 | PROJECT_FILE="Whispera.xcodeproj/project.pbxproj" 10 | INFO_PLIST="Info.plist" 11 | 12 | if [ -z "$VERSION" ]; then 13 | echo "❌ Error: Version number required" 14 | echo "Usage: $0 " 15 | echo "Example: $0 1.0.3" 16 | exit 1 17 | fi 18 | 19 | if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 20 | echo "❌ Error: Invalid version format. Use semantic versioning (e.g., 1.0.3)" 21 | exit 1 22 | fi 23 | 24 | if [ ! -f "$PROJECT_FILE" ]; then 25 | echo "❌ Error: Project file not found: $PROJECT_FILE" 26 | exit 1 27 | fi 28 | 29 | if [ ! -f "$INFO_PLIST" ]; then 30 | echo "❌ Error: Info.plist not found: $INFO_PLIST" 31 | exit 1 32 | fi 33 | 34 | IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" 35 | 36 | CURRENT_BUILD=$(grep -A1 "CFBundleVersion" "$INFO_PLIST" | grep "" | sed 's/.*\(.*\)<\/string>/\1/' | tr -d '\t' | tr -d ' ') 37 | if [[ "$CURRENT_BUILD" =~ ^[0-9]+$ ]]; then 38 | BUILD_NUMBER=$((CURRENT_BUILD + 1)) 39 | else 40 | echo "⚠️ Could not parse current build number, using patch version as build number" 41 | BUILD_NUMBER="$PATCH" 42 | fi 43 | 44 | echo "📋 Version components:" 45 | echo " MARKETING_VERSION: $VERSION" 46 | echo " BUILD_NUMBER: $BUILD_NUMBER" 47 | 48 | cp "$PROJECT_FILE" "$PROJECT_FILE.backup" 49 | cp "$INFO_PLIST" "$INFO_PLIST.backup" 50 | 51 | sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $VERSION/g" "$PROJECT_FILE" 52 | sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $BUILD_NUMBER/g" "$PROJECT_FILE" 53 | sed -i '' -e "/CFBundleShortVersionString<\/key>/{n;s/.*<\/string>/$VERSION<\/string>/;}" "$INFO_PLIST" 54 | sed -i '' -e "/CFBundleVersion<\/key>/{n;s/.*<\/string>/$BUILD_NUMBER<\/string>/;}" "$INFO_PLIST" 55 | echo "🔍 Verifying changes..." 56 | MARKETING_COUNT=$(grep -c "MARKETING_VERSION = $VERSION" "$PROJECT_FILE") 57 | BUILD_COUNT=$(grep -c "CURRENT_PROJECT_VERSION = $BUILD_NUMBER" "$PROJECT_FILE") 58 | 59 | INFO_VERSION=$(grep -A1 "CFBundleShortVersionString" "$INFO_PLIST" | grep "" | sed 's/.*\(.*\)<\/string>/\1/' | tr -d '\t' | tr -d ' ') 60 | INFO_BUILD=$(grep -A1 "CFBundleVersion" "$INFO_PLIST" | grep "" | sed 's/.*\(.*\)<\/string>/\1/' | tr -d '\t' | tr -d ' ') 61 | 62 | echo " project.pbxproj: MARKETING_VERSION entries updated: $MARKETING_COUNT" 63 | echo " project.pbxproj: CURRENT_PROJECT_VERSION entries updated: $BUILD_COUNT" 64 | echo " Info.plist: CFBundleShortVersionString = $INFO_VERSION" 65 | echo " Info.plist: CFBundleVersion = $INFO_BUILD" 66 | 67 | if [ "$MARKETING_COUNT" -eq 0 ] || [ "$BUILD_COUNT" -eq 0 ] || [ "$INFO_VERSION" != "$VERSION" ] || [ "$INFO_BUILD" != "$BUILD_NUMBER" ]; then 68 | echo "❌ Error: Version update failed" 69 | echo "Restoring backups..." 70 | mv "$PROJECT_FILE.backup" "$PROJECT_FILE" 71 | mv "$INFO_PLIST.backup" "$INFO_PLIST" 72 | exit 1 73 | fi 74 | 75 | rm "$PROJECT_FILE.backup" 76 | rm "$INFO_PLIST.backup" 77 | 78 | echo "✅ Version successfully updated to $VERSION (build $BUILD_NUMBER)" 79 | 80 | echo "📄 Changes made:" 81 | echo "project.pbxproj:" 82 | git diff "$PROJECT_FILE" | grep -E "(MARKETING_VERSION|CURRENT_PROJECT_VERSION)" || true 83 | echo "" 84 | echo "Info.plist:" 85 | git diff "$INFO_PLIST" | grep -E "(CFBundleShortVersionString|CFBundleVersion)" -A1 -B1 || true 86 | 87 | if [ "${2:-}" == "--commit" ]; then 88 | echo "📝 Committing version bump..." 89 | git add "$PROJECT_FILE" "$INFO_PLIST" 90 | git commit -m "bump: version $VERSION (build $BUILD_NUMBER)" 91 | echo "✅ Version bump committed" 92 | fi 93 | -------------------------------------------------------------------------------- /PermissionManager.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import AppKit 3 | import ApplicationServices 4 | import Foundation 5 | import Observation 6 | 7 | @Observable 8 | class PermissionManager { 9 | 10 | // MARK: - Observable Properties 11 | var microphonePermissionGranted = false 12 | var accessibilityPermissionGranted = false 13 | var needsPermissions = false 14 | 15 | // MARK: - Private Properties 16 | private var permissionCheckTimer: Timer? 17 | 18 | init() { 19 | updatePermissionStatus() 20 | startPeriodicChecks() 21 | } 22 | 23 | deinit { 24 | permissionCheckTimer?.invalidate() 25 | } 26 | 27 | // MARK: - Public Methods 28 | 29 | /// Updates all permission statuses 30 | func updatePermissionStatus() { 31 | let newMicrophonePermission = checkMicrophonePermission() 32 | let newAccessibilityPermission = checkAccessibilityPermission() 33 | 34 | microphonePermissionGranted = newMicrophonePermission 35 | accessibilityPermissionGranted = newAccessibilityPermission 36 | needsPermissions = !newMicrophonePermission || !newAccessibilityPermission 37 | } 38 | 39 | /// Requests microphone permission 40 | func requestMicrophonePermission() async -> Bool { 41 | return await withCheckedContinuation { continuation in 42 | AVCaptureDevice.requestAccess(for: .audio) { granted in 43 | DispatchQueue.main.async { 44 | self.updatePermissionStatus() 45 | continuation.resume(returning: granted) 46 | } 47 | } 48 | } 49 | } 50 | 51 | /// Opens System Settings to the Privacy & Security section 52 | func openSystemSettings() { 53 | if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy") { 54 | NSWorkspace.shared.open(url) 55 | } 56 | } 57 | 58 | /// Opens Accessibility settings specifically 59 | func openAccessibilitySettings() { 60 | if let url = URL( 61 | string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") 62 | { 63 | NSWorkspace.shared.open(url) 64 | } 65 | } 66 | 67 | /// Opens Microphone settings specifically 68 | func openMicrophoneSettings() { 69 | if let url = URL( 70 | string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") 71 | { 72 | NSWorkspace.shared.open(url) 73 | } 74 | } 75 | 76 | // MARK: - Private Methods 77 | 78 | private func checkMicrophonePermission() -> Bool { 79 | return AVCaptureDevice.authorizationStatus(for: .audio) == .authorized 80 | } 81 | 82 | private func checkAccessibilityPermission() -> Bool { 83 | return AXIsProcessTrusted() 84 | } 85 | 86 | private func startPeriodicChecks() { 87 | // Check permissions every 2 seconds to detect changes 88 | permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { 89 | [weak self] _ in 90 | self?.updatePermissionStatus() 91 | } 92 | } 93 | } 94 | 95 | // MARK: - Permission Status Helpers 96 | 97 | extension PermissionManager { 98 | 99 | /// Returns a user-friendly description of missing permissions 100 | var missingPermissionsDescription: String { 101 | var missing: [String] = [] 102 | 103 | if !microphonePermissionGranted { 104 | missing.append("Microphone access") 105 | } 106 | 107 | if !accessibilityPermissionGranted { 108 | missing.append("Accessibility access") 109 | } 110 | 111 | if missing.isEmpty { 112 | return "All permissions granted" 113 | } else if missing.count == 1 { 114 | return "\(missing[0]) required" 115 | } else { 116 | return "\(missing.joined(separator: " and ")) required" 117 | } 118 | } 119 | 120 | /// Returns the permission status as a color 121 | var permissionStatusColor: NSColor { 122 | return needsPermissions ? .systemOrange : .systemGreen 123 | } 124 | 125 | /// Returns an appropriate system icon for permission status 126 | var permissionStatusIcon: String { 127 | return needsPermissions ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /WhisperaTests/ModelSynchronizationTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Whispera 4 | 5 | @MainActor 6 | final class ModelSynchronizationTests: XCTestCase { 7 | 8 | override func setUp() async throws { 9 | // Reset UserDefaults for clean test state 10 | UserDefaults.standard.removeObject(forKey: "selectedModel") 11 | UserDefaults.standard.removeObject(forKey: "lastUsedModel") 12 | } 13 | 14 | func testSelectedModelInAppStorageMatchesWhisperKitCurrentModel() async throws { 15 | // This test verifies that @AppStorage("selectedModel") stays in sync with WhisperKit's currentModel 16 | 17 | // Given 18 | let transcriber = WhisperKitTranscriber.shared 19 | let testModel = "openai_whisper-base.en" 20 | 21 | // When WhisperKit loads a model 22 | transcriber.currentModel = testModel 23 | transcriber.lastUsedModel = testModel 24 | 25 | // Then selectedModel in UserDefaults should be updated 26 | let selectedModel = UserDefaults.standard.string(forKey: "selectedModel") 27 | XCTAssertEqual( 28 | selectedModel, testModel, 29 | "@AppStorage(selectedModel) should automatically sync with WhisperKit's currentModel") 30 | } 31 | 32 | func testSettingsViewSelectedModelProperty() async throws { 33 | // This tests that SettingsView's selectedModel property correctly reflects the loaded model 34 | 35 | // Given 36 | let testModel = "openai_whisper-small" 37 | UserDefaults.standard.set(testModel, forKey: "selectedModel") 38 | 39 | // When creating SettingsView (simulated) 40 | let selectedModel = UserDefaults.standard.string(forKey: "selectedModel") ?? "" 41 | 42 | // Then it should match what we set 43 | XCTAssertEqual(selectedModel, testModel) 44 | 45 | // When WhisperKit loads a different model 46 | let transcriber = WhisperKitTranscriber.shared 47 | let newModel = "openai_whisper-base" 48 | transcriber.currentModel = newModel 49 | 50 | // Then selectedModel should update (this will fail until we fix the sync) 51 | // In the actual fix, we need to ensure this happens 52 | XCTAssertEqual( 53 | UserDefaults.standard.string(forKey: "selectedModel"), newModel, 54 | "selectedModel should update when WhisperKit loads a different model") 55 | } 56 | 57 | func testIsCurrentModelLoadedLogic() async throws { 58 | // Test the logic that determines if the current model is loaded 59 | 60 | // Given 61 | let transcriber = WhisperKitTranscriber.shared 62 | let testModel = "openai_whisper-base" 63 | 64 | // Scenario 1: No model loaded 65 | transcriber.currentModel = nil 66 | UserDefaults.standard.set(testModel, forKey: "selectedModel") 67 | 68 | XCTAssertFalse( 69 | transcriber.isCurrentModelLoaded(), 70 | "Should return false when no model is loaded") 71 | 72 | // Scenario 2: Same model loaded 73 | transcriber.currentModel = testModel 74 | UserDefaults.standard.set(testModel, forKey: "selectedModel") 75 | 76 | XCTAssertTrue( 77 | transcriber.isCurrentModelLoaded(), 78 | "Should return true when selected model matches loaded model") 79 | 80 | // Scenario 3: Different model selected 81 | UserDefaults.standard.set("openai_whisper-small", forKey: "selectedModel") 82 | 83 | XCTAssertFalse( 84 | transcriber.isCurrentModelLoaded(), 85 | "Should return false when selected model differs from loaded model") 86 | } 87 | 88 | func testModelLoadingUpdatesSelectedModel() async throws { 89 | // Test that loading a model updates the selectedModel 90 | 91 | // Given 92 | let transcriber = WhisperKitTranscriber.shared 93 | UserDefaults.standard.set("openai_whisper-tiny", forKey: "selectedModel") 94 | 95 | // When loading a different model 96 | let newModel = "openai_whisper-base" 97 | // Simulate the loadModel function behavior 98 | transcriber.currentModel = newModel 99 | transcriber.lastUsedModel = newModel 100 | 101 | // Then selectedModel should be updated to match 102 | // This is what needs to be fixed - selectedModel should sync with currentModel 103 | XCTAssertEqual( 104 | UserDefaults.standard.string(forKey: "selectedModel"), newModel, 105 | "selectedModel should be updated when a model is loaded") 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /WhisperaUnitTests/WhisperaUnitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhisperaUnitTests.swift 3 | // WhisperaUnitTests 4 | // 5 | // Created by Varkhuman Mac on 7/3/25. 6 | // 7 | 8 | import SwiftUI 9 | import Testing 10 | 11 | struct WhisperaUnitTests { 12 | 13 | @Test func example() async throws { 14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 15 | } 16 | 17 | } 18 | 19 | struct SettingsViewFrameTests { 20 | 21 | @Test func testSettingsViewFrameDimensions() throws { 22 | // Given - Define the expected frame dimensions from SettingsView.swift:224 23 | let expectedWidth: CGFloat = 400 24 | let expectedHeight: CGFloat = 520 25 | 26 | // When/Then 27 | // Test that the frame dimensions constants are correctly defined 28 | // This ensures we don't accidentally change the frame size 29 | #expect(expectedWidth == 400, "Settings view width should be 400px") 30 | #expect(expectedHeight == 520, "Settings view height should be 520px") 31 | 32 | // Verify these dimensions match what's actually in the SettingsView code 33 | // (This would catch if someone changes the frame without updating tests) 34 | let codeFrameWidth: CGFloat = 400 // From SettingsView.swift line 224 35 | let codeFrameHeight: CGFloat = 520 // From SettingsView.swift line 224 36 | 37 | #expect(expectedWidth == codeFrameWidth, "Test should match actual SettingsView frame width") 38 | #expect(expectedHeight == codeFrameHeight, "Test should match actual SettingsView frame height") 39 | } 40 | 41 | @Test func testSettingsViewContentEstimation() throws { 42 | // Given 43 | let frameHeight: CGFloat = 520 44 | let padding: CGFloat = 20 45 | let availableContentHeight = frameHeight - (padding * 2) // Top and bottom padding 46 | 47 | // When 48 | // Estimate content height based on UI elements 49 | let estimatedElements = [ 50 | ("Global Shortcut", 44), // HStack with button 51 | ("Sound Feedback", 44), // HStack with toggle 52 | ("Sound Pickers", 88), // Two sound picker rows (when enabled) 53 | ("Model Section", 120), // Model picker + status + description 54 | ("Auto Download", 44), // HStack with toggle 55 | ("Translation Mode", 66), // HStack with description 56 | ("Source Language", 66), // HStack with picker 57 | ("Divider", 16), // Divider 58 | ("Launch at Startup", 44), // HStack with toggle 59 | ("Divider", 16), // Divider 60 | ("Setup", 44), // HStack with button 61 | ("Permissions", 100), // Conditional permissions section 62 | ("Spacing", 160), // VStack spacing (16 * 10 elements) 63 | ] 64 | 65 | let estimatedTotalHeight = estimatedElements.reduce(0) { total, element in 66 | total + element.1 67 | } 68 | 69 | // Then 70 | // Verify that our frame height is reasonable for the estimated content 71 | // This is just a rough check - content estimation can vary significantly 72 | #expect(estimatedTotalHeight > 0, "Estimated content height should be positive") 73 | #expect(frameHeight >= 400, "Frame should be at least 400px high") 74 | #expect(frameHeight <= 800, "Frame should not exceed 800px high") 75 | 76 | // Print values for debugging (these will show in test output) 77 | print("Estimated total height: \(estimatedTotalHeight)px") 78 | print("Available content height: \(availableContentHeight)px") 79 | print("Frame height: \(frameHeight)px") 80 | } 81 | 82 | @Test func testSettingsViewFrameIsNotTooLarge() throws { 83 | // Given 84 | let frameHeight: CGFloat = 520 85 | let maxReasonableHeight: CGFloat = 800 // Maximum reasonable height for settings 86 | 87 | // When/Then 88 | // Verify the frame isn't unnecessarily large 89 | #expect( 90 | frameHeight <= maxReasonableHeight, 91 | "Settings view height should not exceed reasonable maximum (\(maxReasonableHeight)px)") 92 | } 93 | 94 | @Test func testSettingsViewFrameIsNotTooSmall() throws { 95 | // Given 96 | let frameHeight: CGFloat = 520 97 | let minReasonableHeight: CGFloat = 400 // Minimum height for usability 98 | 99 | // When/Then 100 | // Verify the frame isn't too small to be usable 101 | #expect( 102 | frameHeight >= minReasonableHeight, 103 | "Settings view height should be at least \(minReasonableHeight)px for usability") 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Onboarding/ShortcutOptionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShortcutsOptionView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | import SwiftUI 8 | 9 | struct ShortcutOptionsView: View { 10 | @Binding var customShortcut: String 11 | @Binding var showingOptions: Bool 12 | @State private var isRecordingShortcut = false 13 | @State private var eventMonitor: Any? 14 | 15 | private let shortcutOptions = [ 16 | "⌥⌘R", "⌃⌘R", "⇧⌘R", 17 | "⌥⌘T", "⌃⌘T", "⇧⌘T", 18 | "⌥⌘V", "⌃⌘V", "⇧⌘V", 19 | ] 20 | 21 | var body: some View { 22 | VStack(spacing: 16) { 23 | Text("Choose a shortcut:") 24 | .font(.subheadline) 25 | .foregroundColor(.secondary) 26 | 27 | // Custom shortcut recording section 28 | VStack(spacing: 12) { 29 | HStack { 30 | Text("Record Custom:") 31 | .font(.subheadline) 32 | .foregroundColor(.primary) 33 | 34 | Spacer() 35 | 36 | Group { 37 | if isRecordingShortcut { 38 | Button(action: { 39 | stopRecording() 40 | }) { 41 | Text("Press keys...") 42 | .font(.system(.caption, design: .monospaced)) 43 | .frame(minWidth: 80) 44 | } 45 | .buttonStyle(PrimaryButtonStyle(isRecording: true)) 46 | .foregroundColor(.white) 47 | } else { 48 | Button(action: { 49 | startRecording() 50 | }) { 51 | Text("Record New") 52 | .font(.system(.caption, design: .monospaced)) 53 | .frame(minWidth: 80) 54 | } 55 | .buttonStyle(SecondaryButtonStyle()) 56 | .foregroundColor(.primary) 57 | } 58 | } 59 | } 60 | 61 | if isRecordingShortcut { 62 | Text("Press Command, Option, Control or Shift + another key") 63 | .font(.caption) 64 | .foregroundColor(.blue) 65 | .multilineTextAlignment(.center) 66 | } 67 | } 68 | .padding() 69 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 70 | 71 | Text("Or choose a preset:") 72 | .font(.caption) 73 | .foregroundColor(.secondary) 74 | 75 | LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) { 76 | ForEach(shortcutOptions, id: \.self) { shortcut in 77 | Group { 78 | if shortcut == customShortcut { 79 | Button(shortcut) { 80 | customShortcut = shortcut 81 | showingOptions = false 82 | } 83 | .buttonStyle(PrimaryButtonStyle(isRecording: false)) 84 | .font(.system(.caption, design: .monospaced)) 85 | } else { 86 | Button(shortcut) { 87 | customShortcut = shortcut 88 | showingOptions = false 89 | } 90 | .buttonStyle(SecondaryButtonStyle()) 91 | .font(.system(.caption, design: .monospaced)) 92 | } 93 | } 94 | } 95 | } 96 | 97 | Button("Cancel") { 98 | showingOptions = false 99 | } 100 | .buttonStyle(TertiaryButtonStyle()) 101 | .font(.caption) 102 | } 103 | .padding() 104 | .background(Color.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 10)) 105 | .onDisappear { 106 | stopRecording() 107 | } 108 | } 109 | 110 | private func startRecording() { 111 | isRecordingShortcut = true 112 | 113 | eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in 114 | if self.isRecordingShortcut { 115 | let shortcut = self.formatKeyEvent(event) 116 | if !shortcut.isEmpty { 117 | self.customShortcut = shortcut 118 | self.stopRecording() 119 | self.showingOptions = false 120 | } 121 | return nil 122 | } 123 | return event 124 | } 125 | } 126 | 127 | private func stopRecording() { 128 | isRecordingShortcut = false 129 | if let monitor = eventMonitor { 130 | NSEvent.removeMonitor(monitor) 131 | eventMonitor = nil 132 | } 133 | } 134 | 135 | private func formatKeyEvent(_ event: NSEvent) -> String { 136 | var parts: [String] = [] 137 | let flags = event.modifierFlags 138 | 139 | if flags.contains(.command) { parts.append("⌘") } 140 | if flags.contains(.option) { parts.append("⌥") } 141 | if flags.contains(.control) { parts.append("⌃") } 142 | if flags.contains(.shift) { parts.append("⇧") } 143 | 144 | if let characters = event.charactersIgnoringModifiers?.uppercased() { 145 | parts.append(characters) 146 | } 147 | 148 | return flags.intersection([.command, .option, .control, .shift]).isEmpty ? "" : parts.joined() 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /GlassBeta.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlassBeta.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/26/25. 6 | // 7 | import SwiftUI 8 | 9 | struct GlassBetaElement: View { 10 | // MARK: - Properties 11 | private let onTap: (() -> Void)? 12 | private let cornerRadius: CGFloat 13 | private let elementSize: CGSize 14 | 15 | // MARK: - Animation State 16 | @State private var shimmerOffset: CGFloat = -100 17 | @State private var pulseScale: CGFloat = 1.0 18 | @State private var isPressed: Bool = false 19 | 20 | // MARK: - Constants 21 | private struct Constants { 22 | static let shimmerWidth: CGFloat = 15 23 | static let shimmerOpacity: Double = 0.2 24 | static let shimmerDuration: Double = 3.0 25 | static let pulseRange: CGFloat = 1.03 26 | static let pulseDuration: Double = 3.0 27 | } 28 | 29 | // MARK: - Initializer 30 | init( 31 | onTap: (() -> Void)? = nil, 32 | cornerRadius: CGFloat = 12, 33 | size: CGSize = CGSize(width: 40, height: 24) 34 | ) { 35 | self.onTap = onTap 36 | self.cornerRadius = cornerRadius 37 | self.elementSize = size 38 | } 39 | 40 | var body: some View { 41 | ZStack { 42 | glassBackground 43 | shimmerOverlay 44 | betaText 45 | } 46 | .scaleEffect(pulseScale) 47 | .scaleEffect(isPressed ? 0.95 : 1.0) 48 | .animation(.easeInOut(duration: 0.1), value: isPressed) 49 | .onAppear(perform: startAnimations) 50 | .onTapGesture(perform: handleTap) 51 | } 52 | } 53 | 54 | // MARK: - View Components 55 | extension GlassBetaElement { 56 | fileprivate var glassBackground: some View { 57 | RoundedRectangle(cornerRadius: cornerRadius) 58 | .fill(.ultraThinMaterial) 59 | .frame(width: elementSize.width, height: elementSize.height) 60 | .background(backgroundGradient) 61 | .overlay(borderGradient) 62 | .shadow(color: Color.blue.opacity(0.15), radius: 8, x: 0, y: 4) 63 | } 64 | 65 | fileprivate var backgroundGradient: some View { 66 | RoundedRectangle(cornerRadius: cornerRadius) 67 | .fill( 68 | LinearGradient( 69 | colors: [ 70 | Color.blue.opacity(0.25), 71 | Color.purple.opacity(0.15), 72 | Color.pink.opacity(0.08), 73 | ], 74 | startPoint: .topLeading, 75 | endPoint: .bottomTrailing 76 | ) 77 | ) 78 | .blur(radius: 0.8) 79 | } 80 | 81 | fileprivate var borderGradient: some View { 82 | RoundedRectangle(cornerRadius: cornerRadius) 83 | .stroke( 84 | LinearGradient( 85 | colors: [ 86 | Color.white.opacity(0.5), 87 | Color.white.opacity(0.08), 88 | ], 89 | startPoint: .topLeading, 90 | endPoint: .bottomTrailing 91 | ), 92 | lineWidth: 0.8 93 | ) 94 | } 95 | 96 | fileprivate var shimmerOverlay: some View { 97 | RoundedRectangle(cornerRadius: cornerRadius) 98 | .fill( 99 | LinearGradient( 100 | colors: [ 101 | Color.clear, 102 | Color.white.opacity(Constants.shimmerOpacity), 103 | Color.clear, 104 | ], 105 | startPoint: .leading, 106 | endPoint: .trailing 107 | ) 108 | ) 109 | .frame(width: Constants.shimmerWidth, height: elementSize.height) 110 | .offset(x: shimmerOffset) 111 | .mask( 112 | RoundedRectangle(cornerRadius: cornerRadius) 113 | .frame(width: elementSize.width, height: elementSize.height) 114 | ) 115 | } 116 | 117 | fileprivate var betaText: some View { 118 | Text("BETA") 119 | .font(.system(size: 9, weight: .bold, design: .default)) 120 | .foregroundColor(.orange) 121 | .shadow(color: Color.black.opacity(0.3), radius: 0.5, x: 0, y: 0.5) 122 | } 123 | } 124 | 125 | // MARK: - Animation Methods 126 | extension GlassBetaElement { 127 | fileprivate func startAnimations() { 128 | startShimmerAnimation() 129 | startPulseAnimation() 130 | } 131 | 132 | fileprivate func startShimmerAnimation() { 133 | withAnimation( 134 | Animation.linear(duration: Constants.shimmerDuration) 135 | .repeatForever(autoreverses: false) 136 | ) { 137 | shimmerOffset = elementSize.width + Constants.shimmerWidth 138 | } 139 | } 140 | 141 | fileprivate func startPulseAnimation() { 142 | withAnimation( 143 | Animation.easeInOut(duration: Constants.pulseDuration) 144 | .repeatForever(autoreverses: true) 145 | ) { 146 | pulseScale = Constants.pulseRange 147 | } 148 | } 149 | 150 | fileprivate func handleTap() { 151 | withAnimation(.easeInOut(duration: 0.1)) { 152 | isPressed = true 153 | } 154 | 155 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 156 | withAnimation(.easeInOut(duration: 0.1)) { 157 | isPressed = false 158 | } 159 | } 160 | 161 | onTap?() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /WhisperaTests/SingleInstanceTests.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import XCTest 3 | 4 | @testable import Whispera 5 | 6 | final class SingleInstanceTests: XCTestCase { 7 | 8 | override func setUp() { 9 | super.setUp() 10 | // Reset any existing instances for clean test environment 11 | } 12 | 13 | override func tearDown() { 14 | super.tearDown() 15 | } 16 | 17 | // MARK: - Single Instance Tests 18 | 19 | func testApplicationDoesNotLaunchMultipleInstances() { 20 | // Test that when app is already running, a second launch activates the existing instance 21 | let appDelegate = AppDelegate() 22 | 23 | // Simulate app already running 24 | let existingApp = NSRunningApplication.current 25 | XCTAssertNotNil(existingApp, "Current app should exist") 26 | 27 | // Test that shouldHandleReopen returns true (activates existing) 28 | let shouldReopen = appDelegate.applicationShouldHandleReopen( 29 | NSApplication.shared, hasVisibleWindows: false) 30 | XCTAssertTrue(shouldReopen, "App should handle reopen when already running") 31 | } 32 | 33 | func testApplicationActivatesWhenAlreadyRunning() { 34 | // Test that existing instance is activated when trying to launch again 35 | let appDelegate = AppDelegate() 36 | let app = NSApplication.shared 37 | 38 | // Simulate reopen attempt 39 | let reopened = appDelegate.applicationShouldHandleReopen(app, hasVisibleWindows: false) 40 | XCTAssertTrue(reopened, "Should activate existing instance") 41 | } 42 | 43 | func testSingleInstanceCheckOnLaunch() { 44 | // Test that app checks for existing instances on launch 45 | let appDelegate = AppDelegate() 46 | 47 | // Mock method to check if another instance exists 48 | let otherInstances = appDelegate.checkForExistingInstances() 49 | 50 | // In test environment, should find only self 51 | XCTAssertEqual(otherInstances.count, 0, "Should not find other instances in test") 52 | } 53 | 54 | func testLaunchAgentDoesNotCreateDuplicates() { 55 | // Test that launch agent configuration prevents duplicates 56 | let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.whisperaapp.Whispera" 57 | let launchAgentURL = FileManager.default.homeDirectoryForCurrentUser 58 | .appendingPathComponent("Library/LaunchAgents") 59 | .appendingPathComponent("\(bundleIdentifier).plist") 60 | 61 | // Check if launch agent exists (from settings) 62 | if FileManager.default.fileExists(atPath: launchAgentURL.path) { 63 | // Read plist and verify configuration 64 | if let plistData = try? Data(contentsOf: launchAgentURL), 65 | let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) 66 | as? [String: Any] 67 | { 68 | 69 | // Verify KeepAlive is false to prevent zombie processes 70 | let keepAlive = plist["KeepAlive"] as? Bool ?? true 71 | XCTAssertFalse(keepAlive, "KeepAlive should be false to prevent duplicate instances") 72 | } 73 | } 74 | } 75 | 76 | func testDockIconClickActivatesExistingInstance() { 77 | // Test that clicking dock icon when app is running doesn't create new instance 78 | let appDelegate = AppDelegate() 79 | 80 | // Simulate dock icon click when app is already running 81 | let shouldReopen = appDelegate.applicationShouldHandleReopen( 82 | NSApplication.shared, hasVisibleWindows: true) 83 | XCTAssertTrue(shouldReopen, "Dock click should activate existing instance") 84 | 85 | // Verify it shows the popover or settings 86 | XCTAssertNotNil(appDelegate.statusItem, "Status item should exist") 87 | } 88 | 89 | func testTerminateExistingInstancesOnLaunch() { 90 | // Test that app can terminate duplicate instances if needed 91 | let appDelegate = AppDelegate() 92 | 93 | // Test the terminate duplicates method 94 | let terminated = appDelegate.terminateDuplicateInstances() 95 | XCTAssertTrue(terminated, "Should be able to terminate duplicates") 96 | } 97 | } 98 | 99 | // MARK: - Mock Extensions for Testing 100 | 101 | extension AppDelegate { 102 | 103 | func checkForExistingInstances() -> [NSRunningApplication] { 104 | // Get all running instances of this app 105 | let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" 106 | let runningApps = NSWorkspace.shared.runningApplications 107 | 108 | return runningApps.filter { app in 109 | app.bundleIdentifier == bundleIdentifier && app != NSRunningApplication.current 110 | } 111 | } 112 | 113 | func terminateDuplicateInstances() -> Bool { 114 | let existingInstances = checkForExistingInstances() 115 | 116 | for instance in existingInstances { 117 | instance.terminate() 118 | } 119 | 120 | return true 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ContextProvider.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Foundation 3 | import OSAKit 4 | 5 | /// Provides system context for command generation 6 | class ContextProvider { 7 | static let shared = ContextProvider() 8 | 9 | private init() {} 10 | 11 | /// Get current system context for command generation 12 | func getCurrentContext() -> String { 13 | var context: [String] = [] 14 | 15 | // Get current directory from Finder 16 | if let finderPath = getCurrentFinderPath() { 17 | context.append("Current directory: \(finderPath)") 18 | } 19 | 20 | // Get frontmost application 21 | if let frontmostApp = getFrontmostApplication() { 22 | context.append("Active application: \(frontmostApp)") 23 | } 24 | 25 | // Get current time 26 | let formatter = DateFormatter() 27 | formatter.dateStyle = .medium 28 | formatter.timeStyle = .short 29 | context.append("Current time: \(formatter.string(from: Date()))") 30 | 31 | return context.isEmpty ? "No additional context available" : context.joined(separator: "\n") 32 | } 33 | 34 | /// Get current Finder path using Accessibility API first, then AppleScript fallback 35 | private func getCurrentFinderPath() -> String? { 36 | // Try Accessibility API first 37 | if let accessibilityPath = getFinderPathViaAccessibility() { 38 | print("🔍 Got Finder path via Accessibility API: \(accessibilityPath)") 39 | return accessibilityPath 40 | } 41 | 42 | // Fallback to AppleScript 43 | if let applescriptPath = getFinderPathViaAppleScript() { 44 | print("🔍 Got Finder path via AppleScript: \(applescriptPath)") 45 | return applescriptPath 46 | } 47 | 48 | print("⚠️ Could not determine current Finder path") 49 | return nil 50 | } 51 | 52 | /// Get Finder path using Accessibility API 53 | private func getFinderPathViaAccessibility() -> String? { 54 | guard 55 | let finderApp = NSRunningApplication.runningApplications( 56 | withBundleIdentifier: "com.apple.finder" 57 | ).first 58 | else { 59 | return nil 60 | } 61 | 62 | let finderElement = AXUIElementCreateApplication(finderApp.processIdentifier) 63 | 64 | // Get all windows 65 | var windowsRef: CFTypeRef? 66 | guard 67 | AXUIElementCopyAttributeValue(finderElement, kAXWindowsAttribute as CFString, &windowsRef) 68 | == .success, 69 | let windows = windowsRef as? [AXUIElement] 70 | else { 71 | return nil 72 | } 73 | 74 | // Find the frontmost Finder window 75 | for window in windows { 76 | var titleRef: CFTypeRef? 77 | guard 78 | AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleRef) == .success, 79 | let title = titleRef as? String 80 | else { 81 | continue 82 | } 83 | 84 | // Skip special Finder windows 85 | if title.isEmpty || title == "Finder" || title.contains("Trash") { 86 | continue 87 | } 88 | 89 | // Convert title to path 90 | let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path 91 | 92 | // Handle common Finder window titles 93 | switch title { 94 | case "Desktop": 95 | return "\(homeDirectory)/Desktop" 96 | case "Documents": 97 | return "\(homeDirectory)/Documents" 98 | case "Downloads": 99 | return "\(homeDirectory)/Downloads" 100 | case "Applications": 101 | return "/Applications" 102 | default: 103 | // For other titles, try to construct path 104 | if title.starts(with: "/") { 105 | return title 106 | } else { 107 | return "\(homeDirectory)/\(title)" 108 | } 109 | } 110 | } 111 | 112 | return nil 113 | } 114 | 115 | /// Get Finder path using AppleScript 116 | private func getFinderPathViaAppleScript() -> String? { 117 | let script = """ 118 | tell application "Finder" 119 | try 120 | set currentFolder to folder of the front window as alias 121 | return POSIX path of currentFolder 122 | on error 123 | return POSIX path of (desktop as alias) 124 | end try 125 | end tell 126 | """ 127 | 128 | let osascript = OSAScript(source: script) 129 | 130 | var error: NSDictionary? 131 | let result = osascript.executeAndReturnError(&error) 132 | 133 | if let error = error { 134 | print("❌ AppleScript error: \(error)") 135 | return nil 136 | } 137 | 138 | guard let stringValue = result?.stringValue else { 139 | return nil 140 | } 141 | 142 | let path = stringValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) 143 | 144 | return path.isEmpty ? nil : path 145 | } 146 | 147 | /// Get frontmost application name 148 | private func getFrontmostApplication() -> String? { 149 | if let frontmostApp = NSWorkspace.shared.frontmostApplication { 150 | return frontmostApp.localizedName 151 | } 152 | return nil 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /FileTranscription/FileTranscriptionProtocols.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - Core File Transcription Protocols 5 | 6 | @MainActor 7 | protocol FileTranscriptionCapable: AnyObject { 8 | var progress: Double { get } 9 | var isTranscribing: Bool { get } 10 | var currentFileName: String? { get } 11 | var error: Error? { get } 12 | 13 | func transcribeFile(at url: URL) async throws -> String 14 | func transcribeFiles(at urls: [URL]) async throws -> [String] 15 | func transcribeFileWithTimestamps(at url: URL) async throws -> [TranscriptionSegment] 16 | func transcribeSegment(at url: URL, startTime: Double, endTime: Double) async throws -> String 17 | func cancelTranscription() 18 | func supportsFileType(_ url: URL) -> Bool 19 | } 20 | 21 | @MainActor 22 | protocol FileDownloadable: AnyObject { 23 | var downloadProgress: Double { get } 24 | var isDownloading: Bool { get } 25 | var bytesDownloaded: Int64 { get } 26 | var totalBytes: Int64 { get } 27 | 28 | func downloadFile(from url: URL) async throws -> URL 29 | func cancelDownload() 30 | } 31 | 32 | @MainActor 33 | protocol DragDropHandler: AnyObject { 34 | var isDragging: Bool { get } 35 | var acceptedFileTypes: Set { get } 36 | 37 | func canAccept(_ info: DropInfo) -> Bool 38 | func handleDrop(_ info: DropInfo) async -> Bool 39 | } 40 | 41 | @MainActor 42 | protocol YouTubeTranscriptionCapable: FileTranscriptionCapable { 43 | var videoInfo: YouTubeVideoInfo? { get } 44 | 45 | func transcribeYouTubeURL(_ url: URL) async throws -> String 46 | func transcribeYouTubeURLWithTimestamps(_ url: URL) async throws -> [TranscriptionSegment] 47 | func transcribeYouTubeSegment(_ url: URL, from startTime: TimeInterval, to endTime: TimeInterval) 48 | async throws -> String 49 | func getVideoInfo(_ url: URL) async throws -> YouTubeVideoInfo 50 | } 51 | 52 | // MARK: - Supporting Data Structures 53 | 54 | struct YouTubeVideoInfo { 55 | let title: String 56 | let duration: TimeInterval 57 | let thumbnailURL: URL? 58 | let videoID: String 59 | } 60 | 61 | struct TranscriptionSegment { 62 | let text: String 63 | let startTime: Double 64 | let endTime: Double 65 | 66 | var formattedTimeRange: String { 67 | return "\(formatTime(startTime)) - \(formatTime(endTime))" 68 | } 69 | 70 | var formattedStartTime: String { 71 | return formatTime(startTime) 72 | } 73 | 74 | var formattedEndTime: String { 75 | return formatTime(endTime) 76 | } 77 | 78 | private func formatTime(_ seconds: Double) -> String { 79 | let minutes = Int(seconds) / 60 80 | let remainingSeconds = Int(seconds) % 60 81 | return String(format: "%d:%02d", minutes, remainingSeconds) 82 | } 83 | 84 | func formatTime(_ seconds: Double, format: TimestampFormat) -> String { 85 | switch format { 86 | case .mmss: 87 | let minutes = Int(seconds) / 60 88 | let remainingSeconds = Int(seconds.truncatingRemainder(dividingBy: 60)) 89 | return String(format: "%d:%02d", minutes, remainingSeconds) 90 | case .hhmmss: 91 | let hours = Int(seconds) / 3600 92 | let minutes = Int(seconds.truncatingRemainder(dividingBy: 3600)) / 60 93 | let remainingSeconds = Int(seconds.truncatingRemainder(dividingBy: 60)) 94 | return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) 95 | case .seconds: 96 | return String(format: "%.1fs", seconds) 97 | } 98 | } 99 | } 100 | 101 | enum TimestampFormat: String, CaseIterable { 102 | case mmss = "MM:SS" 103 | case hhmmss = "HH:MM:SS" 104 | case seconds = "Seconds" 105 | 106 | var displayName: String { 107 | return rawValue 108 | } 109 | } 110 | 111 | enum TranscriptionMode: String, CaseIterable { 112 | case plainText = "Plain Text" 113 | case timestamped = "With Timestamps" 114 | 115 | var displayName: String { 116 | return rawValue 117 | } 118 | } 119 | 120 | // MARK: - File Type Support 121 | 122 | extension FileTranscriptionCapable { 123 | func supportsFileType(_ url: URL) -> Bool { 124 | let fileExtension = url.pathExtension.lowercased() 125 | return SupportedFileTypes.audioFormats.contains(fileExtension) 126 | || SupportedFileTypes.videoFormats.contains(fileExtension) 127 | } 128 | } 129 | 130 | struct SupportedFileTypes { 131 | static let audioFormats: Set = [ 132 | "mp3", "m4a", "wav", "aac", "flac", "aiff", "au", "caf", 133 | ] 134 | 135 | static let videoFormats: Set = [ 136 | "mp4", "mov", "avi", "mkv", "wmv", "flv", "webm", "m4v", 137 | ] 138 | 139 | static let allFormats: Set = { 140 | return audioFormats.union(videoFormats) 141 | }() 142 | 143 | static let formattedDescription: String = { 144 | let audio = audioFormats.map { ".\($0)" }.joined(separator: ", ") 145 | let video = videoFormats.map { ".\($0)" }.joined(separator: ", ") 146 | return "Audio: \(audio)\nVideo: \(video)" 147 | }() 148 | } 149 | -------------------------------------------------------------------------------- /Whispera.xcodeproj/xcshareddata/xcschemes/Whispera.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 18 | 19 | 25 | 31 | 32 | 33 | 34 | 35 | 41 | 42 | 45 | 51 | 52 | 53 | 56 | 62 | 63 | 64 | 65 | 66 | 76 | 78 | 84 | 85 | 86 | 87 | 91 | 92 | 93 | 94 | 100 | 102 | 108 | 109 | 110 | 111 | 113 | 114 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /plan.md: -------------------------------------------------------------------------------- 1 | # Voice-to-Command Automation Plan 2 | 3 | ## Vision ✅ COMPLETED 4 | Transform Whispera into an intelligent voice automation system where users can speak commands naturally and have them executed automatically as bash commands with context awareness. 5 | 6 | ## Phase 1: Core Voice-to-Command System ✅ 7 | - ✅ **Replace clipboard copy with command execution**: When transcription completes, automatically send to LLM for command generation and execution 8 | - ✅ **Integrate with existing LLM infrastructure**: Use current LlamaState.generateAndExecuteBashCommand() method 9 | - ✅ **Add command approval flow**: Show generated command with approve/deny buttons before execution 10 | - ✅ **Context awareness**: Detect current Finder location and pass to LLM as context 11 | 12 | ## Phase 2: Enhanced Context Integration ✅ 13 | - ✅ **Finder integration**: Use AppleScript/Accessibility APIs to get current directory 14 | - ✅ **Application context**: Detect frontmost app and provide relevant context 15 | - ✅ **System state awareness**: Include relevant system information (time, battery, etc.) 16 | - ✅ **Multi-step command support**: Allow LLM to generate command sequences 17 | 18 | ## Phase 3: Interactive Intelligence ✅ 19 | - ✅ **Clarification system**: When LLM needs more info, prompt user with follow-up questions 20 | - ✅ **Learning from history**: Use command history to improve future suggestions 21 | - ✅ **Safety enhancements**: Improved dangerous command detection and warnings 22 | - ✅ **Command templates**: Pre-built patterns for common automation tasks 23 | 24 | ## Phase 4: Advanced Automation ✅ 25 | - ✅ **Workflow chaining**: Link multiple commands together 26 | - ⚠️ **Conditional execution**: Support for if/then logic in voice commands (Basic support via LLM) 27 | - ⚠️ **Integration hooks**: Connect with other automation tools (Future enhancement) 28 | - ⚠️ **Voice feedback**: Speak results back to user using system TTS (Future enhancement) 29 | 30 | ## Implementation Strategy ✅ 31 | 1. ✅ Start with MenuBarView.swift - modify transcription completion to route to LLM instead of clipboard 32 | 2. ✅ Add context providers for Finder path and system state 33 | 3. ✅ Enhance UI with command approval workflow 34 | 4. ✅ Progressively add more context and intelligence features 35 | 36 | ## Key Features IMPLEMENTED ✅ 37 | - ✅ **Natural language input**: "Open the Developer folder" → `open ~/Developer` 38 | - ✅ **Context awareness**: "Show me the files here" (when in Finder) → `ls -la /current/path` 39 | - ✅ **Smart execution**: Automatic approval for safe commands, confirmation for dangerous ones 40 | - ✅ **Command history**: Track and learn from previous successful automations 41 | - ✅ **Multi-modal feedback**: Visual command display + optional voice confirmation 42 | 43 | ## Technical Implementation Details ✅ 44 | 45 | ### Dual Shortcut Architecture ✅ 46 | - **⌘⌥V**: Speech-to-text → clipboard (existing) 47 | - **⌘⌥C**: Speech-to-command → LLM → bash execution (new) 48 | - Shared transcription engine, different post-processing paths 49 | 50 | ### Command Mode Flow ✅ 51 | 1. User triggers command shortcut (⌘⌥C) 52 | 2. Audio recording & transcription (same as existing) 53 | 3. Send transcription + context to LLM 54 | 4. Generate bash command 55 | 5. Show approval dialog with command preview 56 | 6. Execute if approved, with status feedback 57 | 58 | ### Context Integration ✅ 59 | - **Current Finder path**: Uses Accessibility API first, falls back to AppleScript 60 | - **Frontmost app**: NSWorkspace.shared.frontmostApplication 61 | - **System state**: Time, battery level, network connectivity 62 | 63 | ### Safety Features ✅ 64 | - **Dangerous command detection**: (rm, sudo, dd, etc.) 65 | - **Mandatory approval**: For file system modifications 66 | - **Command timeout**: (30 seconds max) 67 | - **Auto-execution setting**: With safety override for dangerous commands 68 | 69 | ### Model Persistence ✅ 70 | - **Auto-save**: Selected model from onboarding 71 | - **Auto-load**: Saved model on app startup with error handling 72 | - **Graceful fallback**: If saved model unavailable 73 | 74 | ## Current Status: COMPLETE ✅ 75 | 76 | All major features have been implemented and are functional: 77 | 78 | 1. ✅ **Model Persistence**: LLM models are saved and auto-loaded on startup 79 | 2. ✅ **Dual Shortcuts**: ⌘⌥V for text mode, ⌘⌥C for command mode 80 | 3. ✅ **Command Approval**: Interactive approval workflow in MenuBarView 81 | 4. ✅ **Auto-Execution Setting**: Optional immediate execution with safety overrides 82 | 5. ✅ **Context Integration**: Finder path detection via Accessibility API + AppleScript fallback 83 | 6. ✅ **Safety Features**: Dangerous command detection and mandatory approval 84 | 7. ✅ **Command History**: Track execution results and success/failure status 85 | 86 | ## Future Enhancements (Optional) 87 | 88 | - **Voice feedback**: Text-to-speech for command results 89 | - **Advanced scripting**: More complex automation workflows 90 | - **External integrations**: Shortcuts app, Automator compatibility 91 | - **Machine learning**: Personalized command suggestions 92 | - **Multi-language support**: Non-English voice commands 93 | 94 | --- 95 | 96 | *Voice-to-command automation system successfully implemented with full safety features, context awareness, and user control.* -------------------------------------------------------------------------------- /scripts/setup-keychain.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Whispera CI Keychain Setup Script 4 | # Securely sets up code signing certificates in CI environment 5 | 6 | set -e 7 | 8 | KEYCHAIN_NAME="whispera-signing.keychain-db" 9 | KEYCHAIN_PATH="$HOME/Library/Keychains/$KEYCHAIN_NAME" 10 | 11 | echo "🔐 Setting up secure keychain for code signing..." 12 | 13 | # Check required environment variables 14 | if [ -z "$DEVELOPER_ID_P12" ]; then 15 | echo "❌ Error: DEVELOPER_ID_P12 environment variable not set" 16 | exit 1 17 | fi 18 | 19 | if [ -z "${DEVELOPER_ID_PASSWORD+x}" ]; then 20 | echo "❌ Error: DEVELOPER_ID_PASSWORD environment variable not set" 21 | exit 1 22 | fi 23 | 24 | # Handle empty password (certificate exported without password) 25 | if [ -z "$DEVELOPER_ID_PASSWORD" ]; then 26 | echo "🔑 Using certificate with empty password" 27 | CERT_PASSWORD="" 28 | else 29 | CERT_PASSWORD="$DEVELOPER_ID_PASSWORD" 30 | fi 31 | 32 | if [ -z "$KEYCHAIN_PASSWORD" ]; then 33 | echo "❌ Error: KEYCHAIN_PASSWORD environment variable not set" 34 | exit 1 35 | fi 36 | 37 | # Clean up any existing keychain 38 | echo "🧹 Cleaning up existing keychains..." 39 | security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true 40 | 41 | # Create temporary certificate file 42 | CERT_FILE="$(mktemp -t whispera-cert).p12" 43 | echo "📜 Decoding certificate..." 44 | echo "🔍 Base64 data length: ${#DEVELOPER_ID_P12} characters" 45 | echo "$DEVELOPER_ID_P12" | base64 --decode > "$CERT_FILE" 46 | 47 | # Verify certificate file was created successfully 48 | if [ ! -f "$CERT_FILE" ] || [ ! -s "$CERT_FILE" ]; then 49 | echo "❌ Error: Failed to decode certificate" 50 | echo "🔍 Temp file: $CERT_FILE" 51 | echo "🔍 File exists: $([ -f "$CERT_FILE" ] && echo "yes" || echo "no")" 52 | echo "🔍 File size: $([ -f "$CERT_FILE" ] && ls -l "$CERT_FILE" || echo "file not found")" 53 | rm -f "$CERT_FILE" 54 | exit 1 55 | fi 56 | 57 | # Create new keychain 58 | echo "🔑 Creating new keychain..." 59 | security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" 60 | 61 | # Set keychain settings 62 | echo "⚙️ Configuring keychain settings..." 63 | security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME" # Lock after 6 hours 64 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" 65 | 66 | # Import certificate 67 | echo "📥 Importing signing certificate..." 68 | echo "🔍 Certificate file size: $(ls -lh "$CERT_FILE" | awk '{print $5}')" 69 | echo "🔍 Certificate file type: $(file "$CERT_FILE")" 70 | 71 | # Try to import with verbose output 72 | security import "$CERT_FILE" \ 73 | -k "$KEYCHAIN_NAME" \ 74 | -P "$CERT_PASSWORD" \ 75 | -T /usr/bin/codesign \ 76 | -T /usr/bin/security \ 77 | -f pkcs12 \ 78 | -A 79 | 80 | # Show what was imported immediately after import 81 | echo "🔍 Checking keychain contents after import..." 82 | echo "All identities in keychain:" 83 | security find-identity -v "$KEYCHAIN_NAME" || echo "No identities found" 84 | echo "All certificates in keychain:" 85 | security find-certificate -a "$KEYCHAIN_NAME" || echo "No certificates found" 86 | echo "Codesigning identities specifically:" 87 | security find-identity -v -p codesigning "$KEYCHAIN_NAME" || echo "No codesigning identities found" 88 | echo "Certificate details:" 89 | security find-certificate -a -p "$KEYCHAIN_NAME" | openssl x509 -noout -text 2>/dev/null | grep -A5 -B5 "Key Usage" || echo "Could not read certificate details" 90 | 91 | # Set key partition list (required for macOS 10.12+) 92 | echo "🔧 Setting key partition list..." 93 | security set-key-partition-list \ 94 | -S apple-tool:,apple: \ 95 | -s -k "$KEYCHAIN_PASSWORD" \ 96 | "$KEYCHAIN_NAME" >/dev/null 2>&1 || true 97 | 98 | # Add to search list 99 | echo "🔍 Adding keychain to search list..." 100 | security list-keychains -s "$KEYCHAIN_NAME" login.keychain 101 | 102 | # Verify certificate is available 103 | echo "✅ Verifying certificate installation..." 104 | 105 | # First, show all available identities for debugging 106 | echo "📋 All available identities in keychain:" 107 | security find-identity -v -p codesigning "$KEYCHAIN_NAME" 108 | 109 | # Count Developer ID certificates (more flexible pattern - accept both types) 110 | CERT_COUNT=$(security find-identity -v -p codesigning "$KEYCHAIN_NAME" | grep -c -E "(Developer ID|3rd Party Mac Developer)" || echo "0") 111 | 112 | if [ "$CERT_COUNT" -eq 0 ]; then 113 | echo "❌ Error: No Developer ID certificates found in keychain" 114 | echo "Available certificates:" 115 | security find-identity -v "$KEYCHAIN_NAME" 116 | security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true 117 | rm -f "$CERT_FILE" 118 | exit 1 119 | fi 120 | 121 | echo "🎯 Found $CERT_COUNT code signing certificate(s)" 122 | 123 | # Show available identities (without private keys) 124 | echo "📋 Available signing identities:" 125 | security find-identity -v -p codesigning "$KEYCHAIN_NAME" | grep -E "(Developer ID|3rd Party Mac Developer)" || security find-identity -v -p codesigning "$KEYCHAIN_NAME" 126 | 127 | # Clean up certificate file 128 | rm -f "$CERT_FILE" 129 | 130 | echo "✅ Keychain setup complete!" 131 | echo "🔑 Keychain: $KEYCHAIN_NAME" 132 | echo "⏰ Auto-lock: 6 hours" 133 | 134 | # Set environment variable for subsequent steps 135 | if [ -n "$GITHUB_ENV" ]; then 136 | echo "SIGNING_KEYCHAIN=$KEYCHAIN_NAME" >> "$GITHUB_ENV" 137 | else 138 | echo "🔧 Local run - GITHUB_ENV not set, skipping environment variable export" 139 | fi -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Whispera 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: "Version to release (e.g., 1.0.3)" 11 | required: true 12 | type: string 13 | 14 | env: 15 | APP_NAME: "Whispera" 16 | SCHEME_NAME: "Whispera" 17 | BUILD_CONFIGURATION: "Release" 18 | 19 | jobs: 20 | release: 21 | runs-on: macos-15 22 | permissions: 23 | contents: write # Required to create releases 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Setup Xcode 32 | uses: maxim-lobanov/setup-xcode@v1 33 | with: 34 | xcode-version: latest-stable 35 | 36 | - name: Get version from tag or input 37 | id: version 38 | run: | 39 | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then 40 | VERSION="${{ inputs.version }}" 41 | else 42 | VERSION=${GITHUB_REF#refs/tags/v} 43 | fi 44 | echo "version=$VERSION" >> $GITHUB_OUTPUT 45 | echo "tag=v$VERSION" >> $GITHUB_OUTPUT 46 | echo "Release version: $VERSION" 47 | 48 | - name: Bump version in project 49 | run: | 50 | chmod +x scripts/bump-version.sh 51 | ./scripts/bump-version.sh "${{ steps.version.outputs.version }}" 52 | 53 | - name: Setup signing keychain 54 | env: 55 | DEVELOPER_ID_P12: ${{ secrets.DEVELOPER_ID_P12 }} 56 | DEVELOPER_ID_PASSWORD: ${{ secrets.DEVELOPER_ID_PASSWORD }} 57 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 58 | run: | 59 | chmod +x scripts/setup-keychain.sh 60 | ./scripts/setup-keychain.sh 61 | 62 | - name: Build and archive 63 | run: | 64 | set -e 65 | 66 | # Clean previous builds 67 | rm -rf build/Release 68 | mkdir -p build/Release 69 | 70 | # Build and archive 71 | xcodebuild -project "${APP_NAME}.xcodeproj" \ 72 | -scheme "${SCHEME_NAME}" \ 73 | -configuration "${BUILD_CONFIGURATION}" \ 74 | -archivePath "./build/Release/${APP_NAME}.xcarchive" \ 75 | -destination "generic/platform=macOS" \ 76 | CODE_SIGN_IDENTITY="" \ 77 | CODE_SIGNING_REQUIRED=NO \ 78 | archive 79 | 80 | - name: Export app 81 | run: | 82 | xcodebuild -exportArchive \ 83 | -archivePath "./build/Release/${APP_NAME}.xcarchive" \ 84 | -exportPath "./build/Release" \ 85 | -exportOptionsPlist "scripts/ExportOptions-dev.plist" 86 | 87 | - name: Sign and notarize 88 | env: 89 | APPLE_ID: ${{ secrets.APPLE_ID }} 90 | APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} 91 | TEAM_ID: ${{ secrets.TEAM_ID }} 92 | run: | 93 | chmod +x scripts/release-distribute-ci.sh 94 | ./scripts/release-distribute-ci.sh 95 | 96 | - name: Create release notes 97 | id: release_notes 98 | run: | 99 | # Get commits since last tag 100 | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 101 | if [ -n "$LAST_TAG" ]; then 102 | COMMITS=$(git log ${LAST_TAG}..HEAD --oneline --pretty=format:"- %s") 103 | else 104 | COMMITS=$(git log --oneline --pretty=format:"- %s" -10) 105 | fi 106 | 107 | cat > release_notes.md << EOF 108 | ## What's Changed 109 | 110 | ${COMMITS} 111 | 112 | ## Download 113 | 114 | Download the \`Whispera.dmg\` file below and drag the app to your Applications folder. 115 | 116 | ## System Requirements 117 | 118 | - macOS 13.0 or later 119 | - Apple Silicon or Intel Mac 120 | - Microphone access permission 121 | 122 | ## Installation Notes 123 | 124 | 1. Download and mount the DMG file 125 | 2. Drag Whispera to Applications folder 126 | 3. First launch: Right-click → Open (to bypass Gatekeeper) 127 | 4. Grant microphone and accessibility permissions when prompted 128 | EOF 129 | 130 | - name: Create GitHub Release 131 | uses: softprops/action-gh-release@v1 132 | with: 133 | tag_name: ${{ steps.version.outputs.tag }} 134 | name: "Whispera ${{ steps.version.outputs.version }}" 135 | body_path: release_notes.md 136 | files: | 137 | dist/Whispera.dmg 138 | dist/Whispera.app.zip 139 | draft: false 140 | prerelease: false 141 | env: 142 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 143 | 144 | - name: Cleanup keychain 145 | if: always() 146 | run: | 147 | # Clean up temporary keychain 148 | if [ -f "scripts/setup-keychain.sh" ]; then 149 | security delete-keychain whispera-signing.keychain-db 2>/dev/null || true 150 | fi 151 | 152 | - name: Upload build artifacts 153 | if: failure() 154 | uses: actions/upload-artifact@v4 155 | with: 156 | name: build-logs 157 | path: | 158 | build/ 159 | *.log 160 | retention-days: 7 161 | 162 | -------------------------------------------------------------------------------- /scripts/release-distribute-ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Whispera CI Release & Distribution Script 4 | # Adapted version of release-distribute.sh for GitHub Actions CI environment 5 | 6 | set -e 7 | 8 | # Configuration 9 | APP_NAME="Whispera" 10 | EXPORT_PATH="./build/Release" 11 | DIST_PATH="./dist" 12 | 13 | # Get Developer ID from keychain (set up by setup-keychain.sh) 14 | DEVELOPER_ID=$(security find-identity -v -p codesigning "${SIGNING_KEYCHAIN:-whispera-signing.keychain-db}" | grep -E "(Developer ID|3rd Party Mac Developer)" | head -1 | sed -n 's/.*"\(.*\)".*/\1/p') 15 | 16 | # Validate environment variables 17 | if [ -z "$APPLE_ID" ]; then 18 | echo "❌ Error: APPLE_ID environment variable not set" 19 | exit 1 20 | fi 21 | 22 | if [ -z "$APP_SPECIFIC_PASSWORD" ]; then 23 | echo "❌ Error: APP_SPECIFIC_PASSWORD environment variable not set" 24 | exit 1 25 | fi 26 | 27 | if [ -z "$TEAM_ID" ]; then 28 | echo "❌ Error: TEAM_ID environment variable not set" 29 | exit 1 30 | fi 31 | 32 | if [ -z "$DEVELOPER_ID" ]; then 33 | echo "❌ Error: Developer ID certificate not found in keychain" 34 | echo "🔍 Available certificates:" 35 | security find-identity -v -p codesigning "${SIGNING_KEYCHAIN:-whispera-signing.keychain-db}" || true 36 | exit 1 37 | fi 38 | 39 | echo "🚀 Starting ${APP_NAME} CI release and distribution..." 40 | echo "🔑 Using Developer ID: $DEVELOPER_ID" 41 | 42 | # Clean and create dist directory 43 | echo "🧹 Preparing distribution directory..." 44 | rm -rf "$DIST_PATH" 45 | mkdir -p "$DIST_PATH" 46 | 47 | # Verify app exists 48 | APP_PATH="${EXPORT_PATH}/${APP_NAME}.app" 49 | if [ ! -d "$APP_PATH" ]; then 50 | echo "❌ Error: App not found at ${APP_PATH}" 51 | echo "🔍 Contents of ${EXPORT_PATH}:" 52 | ls -la "$EXPORT_PATH" || true 53 | exit 1 54 | fi 55 | 56 | echo "📦 Found app at: $APP_PATH" 57 | 58 | # Copy app to dist directory 59 | cp -R "$APP_PATH" "$DIST_PATH/" 60 | 61 | # Sign the app with entitlements 62 | echo "🔏 Signing ${APP_NAME} with entitlements..." 63 | echo "🔑 Certificate: $DEVELOPER_ID" 64 | 65 | # Get the keychain parameter if set 66 | KEYCHAIN_PARAM="" 67 | if [ -n "${SIGNING_KEYCHAIN:-}" ]; then 68 | KEYCHAIN_PARAM="--keychain ${SIGNING_KEYCHAIN}" 69 | fi 70 | 71 | codesign --force --deep --options runtime \ 72 | --entitlements "${APP_NAME}.entitlements" \ 73 | --sign "$DEVELOPER_ID" \ 74 | $KEYCHAIN_PARAM \ 75 | "${DIST_PATH}/${APP_NAME}.app" 76 | 77 | # Verify code signing 78 | echo "🔍 Verifying code signature..." 79 | codesign -vvv --deep --strict "${DIST_PATH}/${APP_NAME}.app" 80 | 81 | if [ $? -eq 0 ]; then 82 | echo "✅ Code signature is valid" 83 | else 84 | echo "❌ Code signature verification failed" 85 | exit 1 86 | fi 87 | 88 | echo "📋 Code signing information:" 89 | codesign --display --verbose=2 "${DIST_PATH}/${APP_NAME}.app" 2>&1 | head -10 90 | 91 | echo "📋 Entitlements embedded:" 92 | codesign --display --entitlements - "${DIST_PATH}/${APP_NAME}.app" | head -20 93 | 94 | # Create zip for notarization 95 | echo "📦 Creating ZIP for notarization..." 96 | cd "$DIST_PATH" 97 | ditto -c -k --keepParent "${APP_NAME}.app" "${APP_NAME}.zip" 98 | 99 | # Create a copy for release artifacts 100 | cp "${APP_NAME}.zip" "${APP_NAME}.app.zip" 101 | 102 | # Submit for notarization 103 | echo "📤 Submitting for notarization..." 104 | echo "🍎 Apple ID: $APPLE_ID" 105 | echo "👥 Team ID: $TEAM_ID" 106 | 107 | NOTARIZATION_OUTPUT=$(xcrun notarytool submit "${APP_NAME}.zip" \ 108 | --apple-id "$APPLE_ID" \ 109 | --password "$APP_SPECIFIC_PASSWORD" \ 110 | --team-id "$TEAM_ID" \ 111 | --wait \ 112 | --output-format json) 113 | 114 | echo "📄 Notarization response:" 115 | echo "$NOTARIZATION_OUTPUT" 116 | 117 | # Check notarization status 118 | NOTARIZATION_STATUS=$(echo "$NOTARIZATION_OUTPUT" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 || echo "unknown") 119 | 120 | echo "🎯 Notarization status: $NOTARIZATION_STATUS" 121 | 122 | if [ "$NOTARIZATION_STATUS" = "Accepted" ]; then 123 | echo "✅ Notarization successful" 124 | 125 | # Staple the notarization 126 | echo "📎 Stapling notarization..." 127 | xcrun stapler staple "${APP_NAME}.app" 128 | 129 | if [ $? -eq 0 ]; then 130 | echo "✅ Stapling successful" 131 | 132 | # Re-create zip with stapled app 133 | rm "${APP_NAME}.zip" 134 | ditto -c -k --keepParent "${APP_NAME}.app" "${APP_NAME}.zip" 135 | cp "${APP_NAME}.zip" "${APP_NAME}.app.zip" 136 | else 137 | echo "⚠️ Stapling failed - app may show security warnings" 138 | fi 139 | else 140 | echo "⚠️ Notarization failed or pending - app may show security warnings" 141 | echo "📋 You can check status later with:" 142 | echo "xcrun notarytool log --apple-id $APPLE_ID --password --team-id $TEAM_ID" 143 | fi 144 | 145 | # Create Applications symlink for DMG 146 | echo "🔗 Creating Applications symlink..." 147 | ln -s /Applications Applications 148 | 149 | # Clean up the zip file before creating DMG (keep the .app.zip for releases) 150 | rm -f "${APP_NAME}.zip" 151 | 152 | # Create DMG 153 | echo "💿 Creating DMG..." 154 | hdiutil create -volname "$APP_NAME" -srcfolder . -ov -format UDZO "${APP_NAME}.dmg" 155 | 156 | # Clean up symlink 157 | rm -f Applications 158 | 159 | # Go back to project root 160 | cd - > /dev/null 161 | 162 | echo "✅ CI distribution complete!" 163 | echo "📦 DMG created: ${DIST_PATH}/${APP_NAME}.dmg" 164 | echo "📱 App bundle: ${DIST_PATH}/${APP_NAME}.app" 165 | echo "🗜️ Zipped app: ${DIST_PATH}/${APP_NAME}.app.zip" 166 | 167 | # Show final file sizes 168 | echo "📊 Release artifacts:" 169 | ls -lh "${DIST_PATH}/"*.dmg "${DIST_PATH}/"*.zip 2>/dev/null || true 170 | 171 | echo "" 172 | echo "🎯 Ready for GitHub release!" -------------------------------------------------------------------------------- /AudioManager/AudioEngineController.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import CoreAudio 3 | import Foundation 4 | 5 | enum AudioEngineError: Error, LocalizedError { 6 | case invalidFormat 7 | case engineNotRunning 8 | case noInputNode 9 | case deviceSetupFailed(String) 10 | 11 | var errorDescription: String? { 12 | switch self { 13 | case .invalidFormat: 14 | return "Invalid audio format" 15 | case .engineNotRunning: 16 | return "Audio engine is not running" 17 | case .noInputNode: 18 | return "No input node available" 19 | case .deviceSetupFailed(let reason): 20 | return "Device setup failed: \(reason)" 21 | } 22 | } 23 | } 24 | 25 | @MainActor 26 | @Observable 27 | final class AudioEngineController { 28 | private(set) var isRunning = false 29 | 30 | @ObservationIgnored 31 | private var engine: AVAudioEngine? 32 | @ObservationIgnored 33 | private var routeObserver: NSObjectProtocol? 34 | @ObservationIgnored 35 | private var isHandlingRouteChange = false 36 | 37 | var onRouteChange: (() async -> Void)? 38 | 39 | var inputNode: AVAudioInputNode? { 40 | engine?.inputNode 41 | } 42 | 43 | var inputFormat: AVAudioFormat? { 44 | engine?.inputNode.outputFormat(forBus: 0) 45 | } 46 | 47 | // MARK: - Setup 48 | 49 | func setup() async throws -> AVAudioInputNode { 50 | cleanup() 51 | 52 | let newEngine = AVAudioEngine() 53 | engine = newEngine 54 | let input = newEngine.inputNode 55 | let format = input.inputFormat(forBus: 0) 56 | 57 | guard format.sampleRate > 0, format.channelCount > 0 else { 58 | throw AudioEngineError.invalidFormat 59 | } 60 | showDeviceName() 61 | try await Task.detached(priority: .userInitiated) { 62 | try newEngine.start() 63 | }.value 64 | isRunning = true 65 | setupRouteObserver() 66 | 67 | return input 68 | } 69 | 70 | // TODO: Improve this function 71 | func showDeviceName() { 72 | var deviceId = AudioDeviceID(0) 73 | var deviceSize = UInt32(MemoryLayout.size(ofValue: deviceId)) 74 | var address = AudioObjectPropertyAddress( 75 | mSelector: kAudioHardwarePropertyDefaultInputDevice, 76 | mScope: kAudioObjectPropertyScopeGlobal, 77 | mElement: kAudioObjectPropertyElementMain 78 | ) 79 | var err = AudioObjectGetPropertyData( 80 | AudioObjectID(kAudioObjectSystemObject), 81 | &address, 82 | 0, 83 | nil, 84 | &deviceSize, 85 | &deviceId 86 | ) 87 | 88 | if err == 0 { 89 | // change the query property and use previously fetched details 90 | address.mSelector = kAudioDevicePropertyDeviceNameCFString 91 | var deviceName = "" as CFString 92 | deviceSize = UInt32(MemoryLayout.size(ofValue: deviceName)) 93 | err = AudioObjectGetPropertyData( 94 | deviceId, 95 | &address, 96 | 0, 97 | nil, 98 | &deviceSize, 99 | &deviceName 100 | ) 101 | if err == 0 { 102 | AppLogger.shared 103 | .audioManager.debug( 104 | "### current default mic:: \(deviceName) " 105 | ) 106 | } else { 107 | // TODO:: unable to fetch device name 108 | } 109 | } else { 110 | // TODO:: unable to fetch the default input device 111 | } 112 | } 113 | 114 | // MARK: - Cleanup 115 | 116 | func cleanup() { 117 | AppLogger.shared.audioManager.debug("🧹 Cleaning up audio engine") 118 | 119 | if let node = engine?.inputNode { 120 | node.removeTap(onBus: 0) 121 | AppLogger.shared.audioManager.debug("✅ Tap removed") 122 | } 123 | 124 | if let engine, engine.isRunning { 125 | engine.stop() 126 | AppLogger.shared.audioManager.debug("✅ Engine stopped") 127 | } 128 | 129 | removeRouteObserver() 130 | engine = nil 131 | isRunning = false 132 | } 133 | 134 | // MARK: - Tap Installation 135 | 136 | func installTap( 137 | bufferSize: AVAudioFrameCount = 1024, 138 | handler: @escaping (AVAudioPCMBuffer, AVAudioFormat) -> Void 139 | ) throws { 140 | guard let node = inputNode else { 141 | throw AudioEngineError.noInputNode 142 | } 143 | 144 | guard isRunning else { 145 | throw AudioEngineError.engineNotRunning 146 | } 147 | 148 | node.installTap(onBus: 0, bufferSize: bufferSize, format: nil) { 149 | buffer, 150 | _ in 151 | handler(buffer, buffer.format) 152 | } 153 | 154 | AppLogger.shared.audioManager.debug("✅ Microphone tap installed") 155 | } 156 | 157 | // MARK: - Device Management 158 | private func getSystemDefaultInputDeviceID() -> AudioDeviceID? { 159 | var deviceID: AudioDeviceID = 0 160 | var size = UInt32(MemoryLayout.size) 161 | 162 | var address = AudioObjectPropertyAddress( 163 | mSelector: kAudioHardwarePropertyDefaultInputDevice, 164 | mScope: kAudioObjectPropertyScopeGlobal, 165 | mElement: kAudioObjectPropertyElementMain 166 | ) 167 | 168 | let status = AudioObjectGetPropertyData( 169 | AudioObjectID(kAudioObjectSystemObject), 170 | &address, 171 | 0, 172 | nil, 173 | &size, 174 | &deviceID 175 | ) 176 | 177 | return status == noErr && deviceID != 0 ? deviceID : nil 178 | } 179 | } 180 | 181 | // MARK: - Private Helpers 182 | 183 | extension AudioEngineController { 184 | fileprivate func setupRouteObserver() { 185 | removeRouteObserver() 186 | 187 | routeObserver = NotificationCenter.default.addObserver( 188 | forName: .AVAudioEngineConfigurationChange, 189 | object: engine, 190 | queue: .main 191 | ) { [weak self] _ in 192 | guard let self else { return } 193 | AppLogger.shared.audioManager.debug( 194 | "🔄 Audio engine configuration changed" 195 | ) 196 | 197 | Task { @MainActor in 198 | guard !self.isHandlingRouteChange else { return } 199 | self.isHandlingRouteChange = true 200 | defer { self.isHandlingRouteChange = false } 201 | await self.onRouteChange?() 202 | } 203 | } 204 | } 205 | 206 | fileprivate func removeRouteObserver() { 207 | if let observer = routeObserver { 208 | NotificationCenter.default.removeObserver(observer) 209 | routeObserver = nil 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /OnboardingView.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import SwiftUI 3 | 4 | struct OnboardingView: View { 5 | @Bindable var audioManager: AudioManager 6 | @ObservedObject var shortcutManager: GlobalShortcutManager 7 | 8 | @State private var currentStep = 0 9 | @State private var selectedModel = "" 10 | @State private var customShortcut = "" 11 | @State private var hasPermissions = false 12 | @State private var launchAtLogin = false 13 | @State private var showingShortcutCapture = false 14 | 15 | @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false 16 | @AppStorage("globalShortcut") private var globalShortcut = "⌥⌘R" 17 | @AppStorage("selectedModel") private var storedModel = "" 18 | @AppStorage("launchAtStartup") private var storedLaunchAtLogin = false 19 | @AppStorage("enableTranslation") private var enableTranslation = false 20 | @AppStorage("enableStreaming") private var enableStreaming = true 21 | @AppStorage("selectedLanguage") private var selectedLanguage = Constants.defaultLanguageName 22 | @AppStorage("materialStyle") private var materialStyleRaw = MaterialStyle.default.rawValue 23 | 24 | private var materialStyle: MaterialStyle { 25 | MaterialStyle(rawValue: materialStyleRaw) 26 | } 27 | 28 | private let steps = [ 29 | "Welcome", "Permissions", "Model", "Shortcut", "Settings", "Test", "Complete", 30 | ] 31 | 32 | var body: some View { 33 | VStack(spacing: 0) { 34 | VStack(spacing: 8) { 35 | Text("Welcome to Whispera") 36 | .font(.system(.title, design: .rounded, weight: .semibold)) 37 | .foregroundColor(.primary) 38 | OnboardingProgressView(currentStep: currentStep, totalSteps: steps.count) 39 | } 40 | .padding(.horizontal, 40) 41 | .padding(.top, 30) 42 | .padding(.bottom, 20) 43 | 44 | ScrollView { 45 | VStack(spacing: 30) { 46 | stepContent 47 | } 48 | .padding(.horizontal, 40) 49 | .padding(.vertical, 30) 50 | } 51 | 52 | // Navigation buttons 53 | HStack(spacing: 16) { 54 | if currentStep > 0 { 55 | Button("Back") { 56 | withAnimation(.easeInOut(duration: 0.3)) { 57 | currentStep -= 1 58 | } 59 | } 60 | .buttonStyle(SecondaryButtonStyle()) 61 | } 62 | 63 | Spacer() 64 | 65 | Button(nextButtonText) { 66 | handleNextStep() 67 | } 68 | .buttonStyle(PrimaryButtonStyle(isRecording: false)) 69 | .disabled(!canProceed) 70 | } 71 | .padding(.horizontal, 40) 72 | .padding(.bottom, 30) 73 | } 74 | .background(materialStyle.material) 75 | .frame(width: 600, height: 750) 76 | .onAppear { 77 | checkPermissions() 78 | // Initialize customShortcut with stored value 79 | customShortcut = globalShortcut 80 | // Initialize launchAtLogin with stored value 81 | launchAtLogin = storedLaunchAtLogin 82 | } 83 | } 84 | 85 | @ViewBuilder 86 | private var stepContent: some View { 87 | switch currentStep { 88 | case 0: 89 | WelcomeStepView() 90 | case 1: 91 | PermissionsStepView( 92 | hasPermissions: $hasPermissions, audioManager: audioManager, 93 | globalShortcutManager: shortcutManager) 94 | case 2: 95 | ModelSelectionStepView(selectedModel: $selectedModel, audioManager: audioManager) 96 | case 3: 97 | ShortcutStepView( 98 | customShortcut: $customShortcut, showingShortcutCapture: $showingShortcutCapture) 99 | case 4: 100 | SettingsStepView(launchAtLogin: $launchAtLogin) 101 | case 5: 102 | TestStepView( 103 | audioManager: audioManager, enableTranslation: $enableTranslation, 104 | selectedLanguage: $selectedLanguage) 105 | case 6: 106 | CompleteStepView() 107 | default: 108 | EmptyView() 109 | } 110 | } 111 | 112 | private var nextButtonText: String { 113 | switch currentStep { 114 | case 0: return "Get Started" 115 | case 1: return (hasPermissions) ? "Continue" : "Grant Permissions" 116 | case 2: 117 | return audioManager.whisperKitTranscriber.isDownloadingModel ? "Downloading..." : "Continue" 118 | case 3: return "Set Shortcut" 119 | case 4: return "Continue" 120 | case 5: return audioManager.lastTranscription != nil ? "Continue" : "Skip Test" 121 | case 6: return "Finish Setup" 122 | default: return "Next" 123 | } 124 | } 125 | 126 | private var canProceed: Bool { 127 | switch currentStep { 128 | case 1: return hasPermissions 129 | case 2: return !audioManager.whisperKitTranscriber.isDownloadingModel 130 | default: return true 131 | } 132 | } 133 | 134 | private func handleNextStep() { 135 | switch currentStep { 136 | case 1: 137 | if !hasPermissions { 138 | requestPermissions() 139 | return 140 | } 141 | case 2: 142 | // Model selection step - model should already be downloaded 143 | storedModel = selectedModel 144 | case 3: 145 | globalShortcut = customShortcut 146 | shortcutManager.currentShortcut = customShortcut 147 | case 4: 148 | storedLaunchAtLogin = launchAtLogin 149 | case 5: 150 | if audioManager.lastTranscription == nil && nextButtonText != "Skip Test" { 151 | return 152 | } 153 | case 6: 154 | completeOnboarding() 155 | return 156 | default: 157 | break 158 | } 159 | 160 | withAnimation { 161 | currentStep += 1 162 | } 163 | } 164 | 165 | private func checkPermissions() { 166 | hasPermissions = AXIsProcessTrusted() 167 | } 168 | 169 | private func requestPermissions() { 170 | // Request accessibility permissions 171 | let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] 172 | AXIsProcessTrustedWithOptions(options) 173 | 174 | // Request microphone permissions if needed 175 | if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined { 176 | AVCaptureDevice.requestAccess(for: .audio) { _ in 177 | // Permission response handled by the view update 178 | } 179 | } 180 | 181 | // Check again after a short delay 182 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 183 | checkPermissions() 184 | } 185 | } 186 | 187 | private func completeOnboarding() { 188 | hasCompletedOnboarding = true 189 | storedModel = selectedModel 190 | 191 | NotificationCenter.default.post(name: NSNotification.Name("OnboardingCompleted"), object: nil) 192 | } 193 | 194 | private func checkMicrophonePermissionStatus() -> Bool { 195 | return AVCaptureDevice.authorizationStatus(for: .audio) == .authorized 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Onboarding/TestStepView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestingStepView.swift 3 | // Whispera 4 | // 5 | // Created by Varkhuman Mac on 7/4/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TestStepView: View { 11 | @Bindable var audioManager: AudioManager 12 | @Binding var enableTranslation: Bool 13 | @Binding var selectedLanguage: String 14 | @AppStorage("globalShortcut") private var globalShortcut = "⌥⌘R" 15 | 16 | var body: some View { 17 | VStack(spacing: 24) { 18 | VStack(spacing: 16) { 19 | Image(systemName: "mic.badge.plus") 20 | .font(.system(size: 48)) 21 | .foregroundColor(.red) 22 | 23 | Text("Test Your Setup") 24 | .font(.system(.title, design: .rounded, weight: .semibold)) 25 | 26 | Text("Configure your language settings and test voice transcription.") 27 | .font(.body) 28 | .foregroundColor(.secondary) 29 | .multilineTextAlignment(.center) 30 | 31 | Text( 32 | "The first transcription might take longer due to the model loading on your device. Especially if it is a larger model." 33 | ) 34 | .font(.callout) 35 | .multilineTextAlignment(.center) 36 | } 37 | 38 | VStack(spacing: 16) { 39 | // Language and Translation Settings 40 | VStack(spacing: 12) { 41 | HStack { 42 | VStack(alignment: .leading, spacing: 2) { 43 | Text("Translation Mode") 44 | .font(.headline) 45 | Text( 46 | enableTranslation 47 | ? "Translate to English" : "Transcribe in original language" 48 | ) 49 | .font(.caption) 50 | .foregroundColor(.secondary) 51 | } 52 | Spacer() 53 | Toggle("", isOn: $enableTranslation) 54 | } 55 | .padding() 56 | .background(Color.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 10)) 57 | 58 | HStack { 59 | VStack(alignment: .leading, spacing: 2) { 60 | Text("Source Language") 61 | .font(.headline) 62 | Text( 63 | enableTranslation 64 | ? "Language to translate from" : "Language to transcribe" 65 | ) 66 | .font(.caption) 67 | .foregroundColor(.secondary) 68 | } 69 | Spacer() 70 | Picker("Language", selection: $selectedLanguage) { 71 | ForEach(Constants.sortedLanguageNames, id: \.self) { language in 72 | Text(language.capitalized).tag(language) 73 | } 74 | } 75 | .frame(minWidth: 120) 76 | } 77 | .padding() 78 | .background(Color.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 10)) 79 | } 80 | 81 | VStack(spacing: 12) { 82 | Text("Press your shortcut to start recording:") 83 | .font(.subheadline) 84 | .foregroundColor(.secondary) 85 | 86 | Text(globalShortcut) 87 | .font(.system(.title, design: .monospaced, weight: .bold)) 88 | .padding(.horizontal, 16) 89 | .padding(.vertical, 8) 90 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 91 | .foregroundColor(.blue) 92 | } 93 | 94 | if audioManager.isRecording { 95 | VStack(spacing: 8) { 96 | HStack(spacing: 8) { 97 | ProgressView() 98 | .scaleEffect(0.8) 99 | Text("Recording... (press shortcut again to stop)") 100 | .font(.caption) 101 | .foregroundColor(.red) 102 | } 103 | } 104 | } 105 | 106 | if audioManager.isTranscribing { 107 | VStack(spacing: 8) { 108 | HStack(spacing: 8) { 109 | ProgressView() 110 | .scaleEffect(0.8) 111 | Text("Transcribing...") 112 | .font(.caption) 113 | .foregroundColor(.secondary) 114 | } 115 | } 116 | } 117 | 118 | if let transcription = audioManager.lastTranscription, !transcription.isEmpty { 119 | VStack(spacing: 12) { 120 | HStack(spacing: 8) { 121 | Image(systemName: "checkmark.circle.fill") 122 | .foregroundColor(.green) 123 | Text("Transcription Complete!") 124 | .font(.subheadline) 125 | .foregroundColor(.green) 126 | } 127 | 128 | VStack(alignment: .leading, spacing: 8) { 129 | Text("Transcribed Text:") 130 | .font(.caption) 131 | .foregroundColor(.secondary) 132 | 133 | Text(transcription) 134 | .font(.system(.body, design: .default)) 135 | .padding() 136 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 137 | .textSelection(.enabled) 138 | } 139 | 140 | Button("Copy to Clipboard") { 141 | NSPasteboard.general.clearContents() 142 | NSPasteboard.general.setString(transcription, forType: .string) 143 | } 144 | .buttonStyle(SecondaryButtonStyle()) 145 | .font(.caption) 146 | } 147 | .padding() 148 | .background(.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 149 | } 150 | 151 | if !audioManager.whisperKitTranscriber.isInitialized { 152 | Text("Waiting for AI framework to initialize...") 153 | .font(.caption) 154 | .foregroundColor(.orange) 155 | } else if !audioManager.whisperKitTranscriber.hasAnyModel() { 156 | Text("Please download a model first to enable transcription.") 157 | .font(.caption) 158 | .foregroundColor(.orange) 159 | .multilineTextAlignment(.center) 160 | } else { 161 | Text("Ready for testing! Use your global shortcut to test.") 162 | .font(.caption) 163 | .foregroundColor(.secondary) 164 | .multilineTextAlignment(.center) 165 | } 166 | 167 | // Current mode indicator 168 | VStack(spacing: 8) { 169 | if enableTranslation { 170 | HStack(spacing: 8) { 171 | Image(systemName: "arrow.right.circle.fill") 172 | .foregroundColor(.green) 173 | Text( 174 | "Translation Mode: Any Supported Language -> \(selectedLanguage.capitalized)" 175 | ) 176 | .font(.caption) 177 | .foregroundColor(.green) 178 | } 179 | } else { 180 | HStack(spacing: 8) { 181 | Image(systemName: "doc.text.fill") 182 | .foregroundColor(.blue) 183 | Text("Transcription Mode: \(selectedLanguage.capitalized)") 184 | .font(.caption) 185 | .foregroundColor(.blue) 186 | } 187 | } 188 | } 189 | .padding() 190 | .background( 191 | (enableTranslation ? Color.green : Color.blue).opacity(0.1), 192 | in: RoundedRectangle(cornerRadius: 8)) 193 | } 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Onboarding/PermissionsStepView.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | // 3 | // PermissionsStepView.swift 4 | // Whispera 5 | // 6 | // Created by Varkhuman Mac on 7/4/25. 7 | // 8 | import SwiftUI 9 | 10 | struct PermissionsStepView: View { 11 | @Binding var hasPermissions: Bool 12 | @Bindable var audioManager: AudioManager 13 | @ObservedObject var globalShortcutManager: GlobalShortcutManager 14 | @State private var hasMicrophonePermission = false 15 | @State private var permissionCheckTimer: Timer? 16 | @State private var accessibilityCheckTimer: Timer? 17 | 18 | var body: some View { 19 | VStack(spacing: 24) { 20 | VStack(spacing: 16) { 21 | Image(systemName: "lock.shield.fill") 22 | .font(.system(size: 48)) 23 | .foregroundColor(.orange) 24 | 25 | Text("Permissions Required") 26 | .font(.system(.title, design: .rounded, weight: .semibold)) 27 | 28 | Text("Whispera needs accessibility permissions to work with global keyboard shortcuts.") 29 | .font(.body) 30 | .foregroundColor(.secondary) 31 | .multilineTextAlignment(.center) 32 | } 33 | 34 | VStack(spacing: 16) { 35 | PermissionRowView( 36 | icon: "key.fill", 37 | title: "Accessibility Access", 38 | description: "Required for global keyboard shortcuts", 39 | isGranted: hasPermissions 40 | ) 41 | 42 | if !hasPermissions { 43 | Button { 44 | globalShortcutManager.requestAccessibilityPermissions() 45 | startPermissionChecking() 46 | } label: { 47 | Text("Grant Accessibility Access") 48 | } 49 | } 50 | 51 | PermissionRowView( 52 | icon: "mic.fill", 53 | title: "Microphone Access", 54 | description: "Required for voice recording", 55 | isGranted: hasMicrophonePermission 56 | ) 57 | if !hasMicrophonePermission { 58 | Button { 59 | requestMicrophonePermission() 60 | } label: { 61 | Text("Grant Microphone Access") 62 | } 63 | } 64 | } 65 | 66 | if hasPermissions && hasMicrophonePermission { 67 | HStack(spacing: 8) { 68 | Image(systemName: "checkmark.circle.fill") 69 | .foregroundColor(.green) 70 | Text("All permissions granted! You're ready to continue.") 71 | .font(.subheadline) 72 | .foregroundColor(.green) 73 | } 74 | .padding() 75 | .background(.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 76 | } else { 77 | VStack(spacing: 12) { 78 | if !hasPermissions { 79 | Text("After clicking \"Grant Permissions\", you'll see a system dialog.") 80 | .font(.subheadline) 81 | .foregroundColor(.secondary) 82 | 83 | Text( 84 | "Go to System Settings > Privacy & Security > Accessibility and enable Whispera." 85 | ) 86 | .font(.subheadline) 87 | .foregroundColor(.secondary) 88 | .multilineTextAlignment(.center) 89 | } 90 | 91 | if !hasMicrophonePermission { 92 | VStack(spacing: 8) { 93 | Text("Microphone access will be requested when you first try to record.") 94 | .font(.subheadline) 95 | .foregroundColor(.secondary) 96 | .multilineTextAlignment(.center) 97 | 98 | Text( 99 | "If Whispera doesn't appear in Microphone settings, try recording first to trigger the permission request." 100 | ) 101 | .font(.caption) 102 | .foregroundColor(.orange) 103 | .multilineTextAlignment(.center) 104 | } 105 | } 106 | } 107 | .padding() 108 | .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 109 | } 110 | } 111 | .onAppear { 112 | checkMicrophonePermission() 113 | checkAccessibilityPermission() 114 | startContinuousPermissionChecking() 115 | } 116 | .onDisappear { 117 | stopPermissionChecking() 118 | stopContinuousPermissionChecking() 119 | } 120 | } 121 | 122 | private func checkMicrophonePermission() { 123 | hasMicrophonePermission = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized 124 | } 125 | 126 | private func checkAccessibilityPermission() { 127 | let newValue = AXIsProcessTrusted() 128 | if newValue != hasPermissions { 129 | hasPermissions = newValue 130 | } 131 | } 132 | 133 | private func startPermissionChecking() { 134 | // Check immediately 135 | checkAccessibilityPermission() 136 | checkMicrophonePermission() 137 | 138 | // Then check every 0.5 seconds for changes 139 | permissionCheckTimer?.invalidate() 140 | permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in 141 | checkAccessibilityPermission() 142 | checkMicrophonePermission() 143 | 144 | // Stop checking once both permissions are granted 145 | if hasPermissions && hasMicrophonePermission { 146 | stopPermissionChecking() 147 | } 148 | } 149 | } 150 | 151 | private func stopPermissionChecking() { 152 | permissionCheckTimer?.invalidate() 153 | permissionCheckTimer = nil 154 | } 155 | 156 | private func startContinuousPermissionChecking() { 157 | // Start a timer that continuously checks for permission changes 158 | accessibilityCheckTimer?.invalidate() 159 | accessibilityCheckTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in 160 | checkAccessibilityPermission() 161 | checkMicrophonePermission() 162 | } 163 | } 164 | 165 | private func stopContinuousPermissionChecking() { 166 | accessibilityCheckTimer?.invalidate() 167 | accessibilityCheckTimer = nil 168 | } 169 | 170 | private func requestMicrophonePermission() { 171 | requestMicrophonePermissionFromUser { granted in 172 | if granted { 173 | self.checkMicrophonePermission() 174 | } 175 | } 176 | } 177 | 178 | private func openMicrophoneSettings() { 179 | if let url = URL( 180 | string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") 181 | { 182 | NSWorkspace.shared.open(url) 183 | } 184 | } 185 | 186 | private func requestMicrophonePermissionFromUser(completion: @escaping (Bool) -> Void) { 187 | switch AVCaptureDevice.authorizationStatus(for: .audio) { 188 | case .authorized: 189 | print("authorized") 190 | completion(true) 191 | 192 | case .notDetermined: 193 | print("notDetermined") 194 | AVCaptureDevice.requestAccess(for: .audio) { granted in 195 | DispatchQueue.main.async { 196 | completion(granted) 197 | } 198 | } 199 | 200 | case .denied, .restricted: 201 | print("denied") 202 | openMicrophoneSettings() 203 | completion(false) 204 | 205 | @unknown default: 206 | print("unknown") 207 | completion(false) 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Whispera is a native macOS app that replaces built-in dictation with OpenAI's Whisper AI for superior transcription accuracy. The app uses WhisperKit for on-device transcription, supporting both real-time audio recording and file transcription with YouTube support. 8 | 9 | ## Build & Development Commands 10 | 11 | ### Building 12 | ```bash 13 | xcodebuild -scheme Whispera -project Whispera.xcodeproj build 14 | ``` 15 | 16 | ### Testing 17 | ```bash 18 | # Run all tests 19 | xcodebuild test -scheme Whispera -project Whispera.xcodeproj 20 | 21 | # Run specific test target 22 | xcodebuild test -scheme Whispera -project Whispera.xcodeproj -only-testing:WhisperaTests 23 | xcodebuild test -scheme Whispera -project Whispera.xcodeproj -only-testing:WhisperaUITests 24 | ``` 25 | 26 | ### Version Management 27 | ```bash 28 | # Bump version (updates both project.pbxproj and Info.plist) 29 | ./scripts/bump-version.sh 1.0.5 30 | 31 | # Bump and commit 32 | ./scripts/bump-version.sh 1.0.5 --commit 33 | ``` 34 | 35 | ### Release Distribution 36 | ```bash 37 | # Create release build and distribute 38 | ./scripts/release-distribute.sh 39 | ``` 40 | 41 | ## Architecture 42 | 43 | ### Core Transcription System 44 | - **WhisperKitTranscriber** (`WhisperKitTranscriber.swift`): Singleton managing WhisperKit integration 45 | - Model downloading, loading, and switching 46 | - Live streaming transcription with segment-based confirmation 47 | - File transcription with timestamps 48 | - Decoding options persistence in UserDefaults 49 | - Real WhisperKit transcription (never simulated) 50 | 51 | - **AudioManager** (`AudioManager.swift`): Handles audio recording 52 | - File-based recording (AVAudioRecorder) 53 | - Streaming recording (AVAudioEngine with 16kHz float buffers) 54 | - Live transcription mode vs text mode 55 | - Recording duration tracking 56 | 57 | - **FileTranscriptionManager** (`FileTranscription/FileTranscriptionManager.swift`): File transcription 58 | - Supports audio/video formats (MP3, WAV, MP4, MOV, etc.) 59 | - Plain text or timestamped transcription 60 | - Progress tracking with real WhisperKit Progress objects 61 | - Task cancellation support 62 | 63 | ### Queue System 64 | - **TranscriptionQueueManager** (`FileTranscription/TranscriptionQueueManager.swift`): Manages transcription queue 65 | - Serial processing of files 66 | - Network file downloads via NetworkFileDownloader 67 | - YouTube downloads via YouTubeTranscriptionManager 68 | - Auto-deletion of downloaded files (configurable) 69 | 70 | ### Global Shortcuts 71 | - **GlobalShortcutManager** (`GlobalShortcutManager.swift`): System-wide hotkey handling 72 | - Text transcription shortcut (default: ⌥⌘R) 73 | - File selection shortcut (default: ⌃F) for Finder integration 74 | - Accessibility permissions required 75 | - Supports file drag-drop, clipboard URLs, and file picker 76 | 77 | ### UI Components 78 | - **WhisperaApp** (`WhisperaApp.swift`): Main app with AppDelegate 79 | - Status bar menu integration (accessory mode) 80 | - Onboarding flow for first launch 81 | - Single instance enforcement 82 | - Animated status icons for different states 83 | 84 | - **MenuBarView** (`MenuBarView.swift`): Status bar popover UI 85 | - **SettingsView** (`SettingsView.swift`): Comprehensive settings panel 86 | - **OnboardingView** (`Onboarding/`): Multi-step onboarding wizard 87 | - **LiveTranscriptionView** (`LiveTranscription/`): Real-time transcription display 88 | 89 | ### Logging 90 | - **AppLogger** (`Logger/`): Centralized logging system 91 | - Category-based loggers (general, audioManager, transcriber, etc.) 92 | - File-based logging with rotation (10MB limit) 93 | - Debug/extended logging modes 94 | - Crash handlers for exception and signal logging 95 | - Use AppLogger instead of print() or os.log 96 | 97 | ## Critical Rules 98 | 99 | ### Transcription 100 | 1. **ALWAYS use real WhisperKit transcription** - never implement simulated/fake responses 101 | 2. Onboarding test MUST use actual `WhisperKit.transcribe()` 102 | 3. If MPS crashes occur, fix the underlying issue rather than simulating 103 | 104 | ### Code Quality 105 | 1. Use `AppLogger.shared.` for logging, not `print()` or `os.log` 106 | 2. Only add comments when syntax needs explanation (why, not what) 107 | 3. Never skip hooks (`--no-verify`, `--no-gpg-sign`) in git commands 108 | 4. Use commitlint format for git commits 109 | 5. Never mention Anthropic or Claude Code in commits/PRs 110 | 111 | ### Settings Storage 112 | - Model settings: `selectedModel`, `lastUsedModel` in UserDefaults 113 | - Decoding options: Persistent via computed properties in WhisperKitTranscriber 114 | - Language: `selectedLanguage` with reactive observation 115 | - Translation: `enableTranslation` flag 116 | - Streaming: `useStreamingTranscription`, `enableStreaming` 117 | 118 | ### State Management 119 | - WhisperKit state: `.unloaded`, `.loading`, `.loaded`, `.prewarmed` 120 | - Download state: `isDownloadingModel` with NotificationCenter observers 121 | - Recording state: `isRecording`, `isTranscribing` with state change notifications 122 | 123 | ## Key Dependencies 124 | 125 | - **WhisperKit**: Main transcription engine (argmaxinc/WhisperKit @ main) 126 | - **swift-transformers**: Hugging Face transformers (0.1.15) 127 | - **YouTubeKit**: YouTube video downloading (0.2.8) 128 | - **swift-markdown-ui**: Markdown rendering for UI (2.4.1) 129 | - **swift-collections**: Advanced collection types (1.2.1) 130 | 131 | ## Common Patterns 132 | 133 | ### Model Operations 134 | Models are downloaded to `~/Library/Application Support/Whispera/models/argmaxinc/whisperkit-coreml/{model-name}/` 135 | - Operations are serialized via `modelOperationTask` 136 | - Download progress tracked via callback 137 | - Models persist across app launches 138 | 139 | ### Live Transcription 140 | - Segments accumulate with 2-segment buffer for pending text 141 | - Confirmed text appends only new segments (prevents duplication) 142 | - `stableDisplayText` for UI, `pendingText` for internal logic 143 | - Session tracking via `DictationWordTracker` 144 | 145 | ### Notifications 146 | - `RecordingStateChanged`: Audio recording state 147 | - `DownloadStateChanged`: Model download state 148 | - `WhisperKitModelStateChanged`: Model loading state 149 | - `QueueProcessingStateChanged`: Queue processing 150 | - `fileTranscriptionSuccess/Error`: File transcription results 151 | 152 | ## Testing Considerations 153 | 154 | - Tests should use real WhisperKit when testing transcription 155 | - Mock only external dependencies (network, file system) 156 | - Use `@MainActor` for SwiftUI-related test operations 157 | - Test files located in `WhisperaTests/`, `WhisperaUITests/` 158 | -------------------------------------------------------------------------------- /WhisperaTests/AudioManagerTests.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import XCTest 3 | 4 | @testable import Whispera 5 | 6 | @MainActor 7 | final class AudioManagerTests: XCTestCase { 8 | 9 | var audioManager: AudioManager! 10 | 11 | override func setUp() async throws { 12 | audioManager = AudioManager() 13 | } 14 | 15 | override func tearDown() async throws { 16 | audioManager = nil 17 | } 18 | 19 | // MARK: - Streaming Mode Tests 20 | 21 | func testStreamingModeToggle() async throws { 22 | // Given 23 | let initialStreamingMode = audioManager.useStreamingTranscription 24 | 25 | // When 26 | audioManager.useStreamingTranscription = !initialStreamingMode 27 | 28 | // Then 29 | XCTAssertNotEqual( 30 | audioManager.useStreamingTranscription, initialStreamingMode, "Streaming mode should toggle") 31 | } 32 | 33 | func testDefaultStreamingModeIsEnabled() async throws { 34 | // Given & When 35 | let audioManager = AudioManager() 36 | 37 | // Then 38 | XCTAssertTrue( 39 | audioManager.useStreamingTranscription, "Streaming mode should be enabled by default") 40 | } 41 | 42 | func testStreamingModeStorage() async throws { 43 | // Given 44 | let testValue = false 45 | 46 | // When 47 | audioManager.useStreamingTranscription = testValue 48 | 49 | // Then 50 | let storedValue = UserDefaults.standard.bool(forKey: "useStreamingTranscription") 51 | XCTAssertEqual( 52 | storedValue, testValue, "Streaming mode preference should be stored in UserDefaults") 53 | } 54 | 55 | func testRecordingModeEnumValues() async throws { 56 | // Test that our RecordingMode enum has the expected values 57 | XCTAssertEqual(RecordingMode.text.rawValue, RecordingMode.text.rawValue) 58 | } 59 | 60 | func testAudioManagerInitialization() async throws { 61 | // Given & When 62 | let manager = AudioManager() 63 | 64 | // Then 65 | XCTAssertFalse(manager.isRecording, "Should not be recording on initialization") 66 | XCTAssertFalse(manager.isTranscribing, "Should not be transcribing on initialization") 67 | XCTAssertNil(manager.lastTranscription, "Should have no transcription on initialization") 68 | XCTAssertNil(manager.transcriptionError, "Should have no error on initialization") 69 | XCTAssertEqual(manager.currentRecordingMode, .text, "Should default to text mode") 70 | } 71 | 72 | func testToggleRecordingWithStreamingMode() async throws { 73 | // Given 74 | audioManager.useStreamingTranscription = true 75 | let initialRecordingState = audioManager.isRecording 76 | 77 | // When 78 | audioManager.toggleRecording(mode: .text) 79 | 80 | // Then 81 | // Note: This test may not actually start recording due to permissions in test environment 82 | // We're mainly testing that the method doesn't crash and handles the flow 83 | XCTAssertEqual(audioManager.currentRecordingMode, .text, "Recording mode should be set") 84 | } 85 | 86 | func testToggleRecordingWithFileMode() async throws { 87 | // Given 88 | audioManager.useStreamingTranscription = false 89 | let initialRecordingState = audioManager.isRecording 90 | 91 | // When 92 | audioManager.toggleRecording(mode: .text) 93 | 94 | // Then 95 | // Note: This test may not actually start recording due to permissions in test environment 96 | // We're mainly testing that the method doesn't crash and handles the flow 97 | XCTAssertEqual(audioManager.currentRecordingMode, .text, "Recording mode should be set") 98 | } 99 | 100 | func testApplicationSupportDirectoryCreation() async throws { 101 | // When 102 | let directory = audioManager.getApplicationSupportDirectory() 103 | 104 | // Then 105 | XCTAssertTrue( 106 | FileManager.default.fileExists(atPath: directory.path), 107 | "Application support directory should exist") 108 | XCTAssertTrue(directory.path.contains("Whispera"), "Directory should contain app name") 109 | } 110 | 111 | func testWhisperKitTranscriberReference() async throws { 112 | // Given & When 113 | let transcriber = audioManager.whisperKitTranscriber 114 | 115 | // Then 116 | XCTAssertNotNil(transcriber, "AudioManager should have a WhisperKit transcriber reference") 117 | XCTAssertTrue( 118 | transcriber === WhisperKitTranscriber.shared, "Should reference the shared instance") 119 | } 120 | 121 | // MARK: - Audio Buffer Tests 122 | 123 | func testAudioBufferProperties() async throws { 124 | // Test that buffer size constants are reasonable 125 | let maxBufferSize = 16000 * 30 // 30 seconds at 16kHz 126 | XCTAssertEqual(maxBufferSize, 480000, "Buffer size should accommodate 30 seconds of audio") 127 | 128 | let audioFormat = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) 129 | XCTAssertNotNil(audioFormat, "Should be able to create 16kHz mono audio format") 130 | XCTAssertEqual(audioFormat?.sampleRate, 16000, "Sample rate should be 16kHz") 131 | XCTAssertEqual(audioFormat?.channelCount, 1, "Should be mono audio") 132 | } 133 | 134 | // MARK: - Error Handling Tests 135 | 136 | func testTranscriptionErrorHandling() async throws { 137 | // Given 138 | let testError = "Test transcription error" 139 | 140 | // When 141 | audioManager.transcriptionError = testError 142 | 143 | // Then 144 | XCTAssertEqual(audioManager.transcriptionError, testError, "Should store transcription error") 145 | } 146 | 147 | func testRecordingStateNotifications() async throws { 148 | // Given 149 | let expectation = XCTestExpectation(description: "Recording state notification") 150 | let notificationCenter = NotificationCenter.default 151 | 152 | var receivedNotification = false 153 | let observer = notificationCenter.addObserver( 154 | forName: NSNotification.Name("RecordingStateChanged"), 155 | object: nil, 156 | queue: .main 157 | ) { _ in 158 | receivedNotification = true 159 | expectation.fulfill() 160 | } 161 | 162 | // When 163 | audioManager.isRecording = true 164 | 165 | // Then 166 | await fulfillment(of: [expectation], timeout: 1.0) 167 | XCTAssertTrue(receivedNotification, "Should receive recording state change notification") 168 | 169 | notificationCenter.removeObserver(observer) 170 | } 171 | 172 | // MARK: - Integration Tests 173 | 174 | func testSetupAudioWithStreamingMode() async throws { 175 | // Given 176 | audioManager.useStreamingTranscription = true 177 | 178 | // When 179 | audioManager.setupAudio() 180 | 181 | // Then 182 | // Test doesn't crash - specific audio engine testing would require more complex mocking 183 | XCTAssertTrue(audioManager.useStreamingTranscription, "Streaming mode should remain enabled") 184 | } 185 | 186 | func testSetupAudioWithFileMode() async throws { 187 | // Given 188 | audioManager.useStreamingTranscription = false 189 | 190 | // When 191 | audioManager.setupAudio() 192 | 193 | // Then 194 | // Test doesn't crash - specific file-based recording testing would require more complex mocking 195 | XCTAssertFalse(audioManager.useStreamingTranscription, "File mode should remain enabled") 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /docs/CICD_SETUP.md: -------------------------------------------------------------------------------- 1 | # CI/CD Setup Guide for Whispera 2 | 3 | This document explains how to set up the automated CI/CD pipeline for Whispera using GitHub Actions. 4 | 5 | ## Overview 6 | 7 | The CI/CD pipeline provides: 8 | - **Automated testing** on every PR 9 | - **Automated releases** when you push tags 10 | - **Code signing and notarization** with Apple 11 | - **DMG distribution** via GitHub Releases 12 | 13 | ## 🔐 Required GitHub Repository Secrets 14 | 15 | ### Step 1: Obtain Your Apple Developer Credentials 16 | 17 | You'll need these from your Apple Developer account: 18 | 19 | 1. **Apple ID**: Your Apple developer account email 20 | 2. **App-Specific Password**: Generate at [appleid.apple.com](https://appleid.apple.com) 21 | 3. **Team ID**: Found in Apple Developer account settings 22 | 4. **Developer ID Certificate**: Export from Keychain Access 23 | 24 | ### Step 2: Export Your Developer ID Certificate 25 | 26 | 1. Open **Keychain Access** on your Mac 27 | 2. Find your "Developer ID Application" certificate 28 | 3. Right-click → "Export..." 29 | 4. Save as `.p12` file with a password 30 | 5. Convert to base64: 31 | ```bash 32 | base64 -i YourCert.p12 | pbcopy 33 | ``` 34 | 35 | ### Step 3: Set Up GitHub Repository Secrets 36 | 37 | #### Option A: Using GitHub CLI (Recommended) 38 | 39 | ```bash 40 | # Set up all repository secrets using GitHub CLI 41 | gh secret set APPLE_ID --body "your-apple-id@example.com" 42 | gh secret set APP_SPECIFIC_PASSWORD --body "abcd-efgh-ijkl-mnop" 43 | gh secret set TEAM_ID --body "NK28QT38A3" 44 | gh secret set DEVELOPER_ID_P12 --body "$(base64 -i YourCert.p12)" 45 | gh secret set DEVELOPER_ID_PASSWORD --body "your-cert-password" 46 | gh secret set KEYCHAIN_PASSWORD --body "$(openssl rand -base64 32)" 47 | ``` 48 | 49 | #### Option B: Using GitHub Web Interface 50 | 51 | Go to your GitHub repository → Settings → Secrets and variables → Actions 52 | 53 | Add these **Repository secrets**: 54 | 55 | | Secret Name | Description | Example | 56 | |-------------|-------------|---------| 57 | | `APPLE_ID` | Your Apple ID email | `developer@example.com` | 58 | | `APP_SPECIFIC_PASSWORD` | App-specific password from Apple | `abcd-efgh-ijkl-mnop` | 59 | | `TEAM_ID` | Your Apple Developer Team ID | `NK28QT38A3` | 60 | | `DEVELOPER_ID_P12` | Base64 encoded certificate | `MIIKs...` (very long) | 61 | | `DEVELOPER_ID_PASSWORD` | Certificate password | `your-cert-password` | 62 | | `KEYCHAIN_PASSWORD` | Temporary keychain password | `ci-temp-password-123` | 63 | 64 | ### Step 4: Verify Setup 65 | 66 | After setting up secrets, the workflows will be available: 67 | 68 | - **PR Testing**: Runs automatically on pull requests 69 | - **Release**: Triggers when you push a version tag 70 | 71 | ## 🚀 How to Create a Release 72 | 73 | ### Option 1: Tag-Based Release (Recommended) 74 | 75 | ```bash 76 | # Create and push a version tag 77 | git tag v1.0.3 78 | git push origin v1.0.3 79 | ``` 80 | 81 | This automatically: 82 | 1. Bumps version in project files 83 | 2. Builds and signs the app 84 | 3. Notarizes with Apple 85 | 4. Creates GitHub release with DMG 86 | 87 | ### Option 2: Manual Release 88 | 89 | 1. Go to GitHub → Actions → "Release Whispera" 90 | 2. Click "Run workflow" 91 | 3. Enter version number (e.g., `1.0.3`) 92 | 4. Click "Run workflow" 93 | 94 | ## 📋 What Each Workflow Does 95 | 96 | ### Build and Test (`build-test.yml`) 97 | 98 | **Triggers**: Every PR and push to main/develop 99 | 100 | **Steps**: 101 | - ✅ Builds the app (unsigned) 102 | - ✅ Runs unit tests 103 | - ✅ Runs UI tests (with error tolerance) 104 | - ✅ Code quality checks 105 | - ✅ Comments PR with results 106 | 107 | ### Release (`release.yml`) 108 | 109 | **Triggers**: Version tags (`v*.*.*`) or manual dispatch 110 | 111 | **Steps**: 112 | - 🔢 Bumps version numbers 113 | - 🔐 Sets up secure keychain 114 | - 🔨 Builds and archives app 115 | - 🔏 Signs with Developer ID 116 | - 📤 Notarizes with Apple 117 | - 💿 Creates DMG 118 | - 🚀 Creates GitHub release 119 | 120 | ## 🛠️ Troubleshooting 121 | 122 | ### Common Issues 123 | 124 | **"No Developer ID certificate found"** 125 | - Verify `DEVELOPER_ID_P12` secret is set correctly 126 | - Check certificate is not expired 127 | - Ensure certificate includes private key 128 | 129 | **"Notarization failed"** 130 | - Verify Apple ID credentials 131 | - Check app has all required entitlements 132 | - Ensure app is properly signed 133 | 134 | **"Build failed"** 135 | - Check unit/UI tests pass locally 136 | - Verify Xcode project builds without errors 137 | - Review build logs in Actions tab 138 | 139 | ### Debug Steps 140 | 141 | 1. **Check secrets**: Verify all secrets are set 142 | ```bash 143 | # List all repository secrets 144 | gh secret list 145 | ``` 146 | 2. **Review logs**: Go to Actions tab → failed workflow → review detailed logs 147 | ```bash 148 | # View latest workflow run 149 | gh run list --limit 1 150 | gh run view --log-failed 151 | ``` 152 | 3. **Test locally**: Run `scripts/bump-version.sh 1.0.0` to test version bumping 153 | 4. **Verify certificates**: Check your Developer ID certificate is valid 154 | 155 | ### Getting Help 156 | 157 | - Check the [Actions tab](../../actions) for detailed logs 158 | - Review [Apple's notarization guide](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) 159 | - Ensure your Apple Developer account is in good standing 160 | 161 | ## 🔄 Maintenance 162 | 163 | ### Updating Secrets 164 | 165 | If you need to rotate credentials: 166 | ```bash 167 | # Update individual secrets 168 | gh secret set APPLE_ID --body "new-apple-id@example.com" 169 | gh secret set APP_SPECIFIC_PASSWORD --body "new-password" 170 | 171 | # Or update certificate 172 | gh secret set DEVELOPER_ID_P12 --body "$(base64 -i NewCert.p12)" 173 | gh secret set DEVELOPER_ID_PASSWORD --body "new-cert-password" 174 | ``` 175 | 176 | Next release will automatically use the new credentials. 177 | 178 | ### Modifying Workflows 179 | 180 | The workflow files are in `.github/workflows/`: 181 | - Edit locally and commit changes 182 | - Test changes on a feature branch first 183 | - Workflows update automatically when merged 184 | 185 | ## 📊 Release Artifacts 186 | 187 | Each successful release creates: 188 | - **DMG file**: For easy distribution to users 189 | - **Zipped app**: Alternative download format 190 | - **Release notes**: Auto-generated from git commits 191 | 192 | Users can download directly from the [Releases page](../../releases). 193 | 194 | --- 195 | 196 | ## 🎯 Quick Start Checklist 197 | 198 | - [ ] Export your Developer ID certificate as `.p12` file 199 | - [ ] Set up all 6 GitHub repository secrets using GitHub CLI: 200 | ```bash 201 | gh secret set APPLE_ID --body "your-email@example.com" 202 | gh secret set APP_SPECIFIC_PASSWORD --body "your-app-password" 203 | gh secret set TEAM_ID --body "YOUR_TEAM_ID" 204 | gh secret set DEVELOPER_ID_P12 --body "$(base64 -i YourCert.p12)" 205 | gh secret set DEVELOPER_ID_PASSWORD --body "cert-password" 206 | gh secret set KEYCHAIN_PASSWORD --body "$(openssl rand -base64 32)" 207 | ``` 208 | - [ ] Verify secrets are set: `gh secret list` 209 | - [ ] Test with a sample tag: `git tag v1.0.0-test && git push origin v1.0.0-test` 210 | - [ ] Check release appears: `gh run list` 211 | - [ ] Download and test the generated DMG 212 | - [ ] Clean up test: `git tag -d v1.0.0-test && git push origin :v1.0.0-test` 213 | 214 | Your automated pipeline is ready! 🎉 -------------------------------------------------------------------------------- /Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | enum MaterialStyle: String, CaseIterable, Identifiable { 5 | case ultraThin = "Ultra Thin" 6 | case thin = "Thin" 7 | case regular = "Regular" 8 | case thick = "Thick" 9 | case ultraThick = "Ultra Thick" 10 | 11 | var id: String { rawValue } 12 | 13 | var material: Material { 14 | switch self { 15 | case .ultraThin: return .ultraThinMaterial 16 | case .thin: return .thinMaterial 17 | case .regular: return .regularMaterial 18 | case .thick: return .thickMaterial 19 | case .ultraThick: return .ultraThickMaterial 20 | } 21 | } 22 | 23 | static var `default`: MaterialStyle { .thin } 24 | } 25 | 26 | //enum GlassStyle: String, CaseIterable, Identifiable { 27 | // 28 | // var glass: Glass { 29 | // switch self { 30 | // case . 31 | // } 32 | // } 33 | //} 34 | 35 | struct Constants { 36 | public static let languages: [String: String] = [ 37 | "english": "en", 38 | "chinese": "zh", 39 | "german": "de", 40 | "spanish": "es", 41 | "russian": "ru", 42 | "korean": "ko", 43 | "french": "fr", 44 | "japanese": "ja", 45 | "portuguese": "pt", 46 | "turkish": "tr", 47 | "polish": "pl", 48 | "catalan": "ca", 49 | "dutch": "nl", 50 | "arabic": "ar", 51 | "swedish": "sv", 52 | "italian": "it", 53 | "indonesian": "id", 54 | "hindi": "hi", 55 | "finnish": "fi", 56 | "vietnamese": "vi", 57 | "hebrew": "he", 58 | "ukrainian": "uk", 59 | "greek": "el", 60 | "malay": "ms", 61 | "czech": "cs", 62 | "romanian": "ro", 63 | "danish": "da", 64 | "hungarian": "hu", 65 | "tamil": "ta", 66 | "norwegian": "no", 67 | "thai": "th", 68 | "urdu": "ur", 69 | "croatian": "hr", 70 | "bulgarian": "bg", 71 | "lithuanian": "lt", 72 | "latin": "la", 73 | "maori": "mi", 74 | "malayalam": "ml", 75 | "welsh": "cy", 76 | "slovak": "sk", 77 | "telugu": "te", 78 | "persian": "fa", 79 | "latvian": "lv", 80 | "bengali": "bn", 81 | "serbian": "sr", 82 | "azerbaijani": "az", 83 | "slovenian": "sl", 84 | "kannada": "kn", 85 | "estonian": "et", 86 | "macedonian": "mk", 87 | "breton": "br", 88 | "basque": "eu", 89 | "icelandic": "is", 90 | "armenian": "hy", 91 | "nepali": "ne", 92 | "mongolian": "mn", 93 | "bosnian": "bs", 94 | "kazakh": "kk", 95 | "albanian": "sq", 96 | "swahili": "sw", 97 | "galician": "gl", 98 | "marathi": "mr", 99 | "punjabi": "pa", 100 | "sinhala": "si", 101 | "khmer": "km", 102 | "shona": "sn", 103 | "yoruba": "yo", 104 | "somali": "so", 105 | "afrikaans": "af", 106 | "occitan": "oc", 107 | "georgian": "ka", 108 | "belarusian": "be", 109 | "tajik": "tg", 110 | "sindhi": "sd", 111 | "gujarati": "gu", 112 | "amharic": "am", 113 | "yiddish": "yi", 114 | "lao": "lo", 115 | "uzbek": "uz", 116 | "faroese": "fo", 117 | "haitian creole": "ht", 118 | "pashto": "ps", 119 | "turkmen": "tk", 120 | "nynorsk": "nn", 121 | "maltese": "mt", 122 | "sanskrit": "sa", 123 | "luxembourgish": "lb", 124 | "myanmar": "my", 125 | "tibetan": "bo", 126 | "tagalog": "tl", 127 | "malagasy": "mg", 128 | "assamese": "as", 129 | "tatar": "tt", 130 | "hawaiian": "haw", 131 | "lingala": "ln", 132 | "hausa": "ha", 133 | "bashkir": "ba", 134 | "javanese": "jw", 135 | "sundanese": "su", 136 | "cantonese": "yue", 137 | "burmese": "my", 138 | "valencian": "ca", 139 | "flemish": "nl", 140 | "haitian": "ht", 141 | "letzeburgesch": "lb", 142 | "pushto": "ps", 143 | "panjabi": "pa", 144 | "moldavian": "ro", 145 | "moldovan": "ro", 146 | "sinhalese": "si", 147 | "castilian": "es", 148 | "mandarin": "zh", 149 | ] 150 | 151 | public static let defaultLanguageCode = "en" 152 | public static let defaultLanguageName = "english" 153 | 154 | // Helper to get sorted language names for UI 155 | public static var sortedLanguageNames: [String] { 156 | return Array(languages.keys).sorted() 157 | } 158 | 159 | // Helper to get language code from name 160 | public static func languageCode(for languageName: String) -> String { 161 | return languages[languageName.lowercased()] ?? defaultLanguageCode 162 | } 163 | 164 | // Helper to get language name from code 165 | public static func languageName(for languageCode: String) -> String { 166 | return languages.first { $0.value == languageCode }?.key.capitalized 167 | ?? defaultLanguageName.capitalized 168 | } 169 | 170 | private static let keyboardIdentifierToLanguageCode: [String: String] = [ 171 | "com.apple.keylayout.US": "en", 172 | "com.apple.keylayout.ABC": "en", 173 | "com.apple.keylayout.USInternational-PC": "en", 174 | "com.apple.keylayout.British": "en", 175 | "com.apple.keylayout.Australian": "en", 176 | "com.apple.keylayout.Canadian": "en", 177 | "com.apple.keylayout.Russian": "ru", 178 | "com.apple.keylayout.RussianWin": "ru", 179 | "com.apple.keylayout.Russian-Phonetic": "ru", 180 | "com.apple.keylayout.Spanish": "es", 181 | "com.apple.keylayout.Spanish-ISO": "es", 182 | "com.apple.keylayout.German": "de", 183 | "com.apple.keylayout.French": "fr", 184 | "com.apple.keylayout.French-PC": "fr", 185 | "com.apple.keylayout.Italian": "it", 186 | "com.apple.keylayout.Portuguese": "pt", 187 | "com.apple.keylayout.PortugueseBrazilian": "pt", 188 | "com.apple.keylayout.Dutch": "nl", 189 | "com.apple.keylayout.Swedish": "sv", 190 | "com.apple.keylayout.Norwegian": "no", 191 | "com.apple.keylayout.Danish": "da", 192 | "com.apple.keylayout.Finnish": "fi", 193 | "com.apple.keylayout.Polish": "pl", 194 | "com.apple.keylayout.PolishPro": "pl", 195 | "com.apple.keylayout.Czech": "cs", 196 | "com.apple.keylayout.Hungarian": "hu", 197 | "com.apple.keylayout.Romanian": "ro", 198 | "com.apple.keylayout.Turkish": "tr", 199 | "com.apple.keylayout.Greek": "el", 200 | "com.apple.keylayout.Hebrew": "he", 201 | "com.apple.keylayout.Arabic": "ar", 202 | "com.apple.keylayout.Korean": "ko", 203 | "com.apple.keylayout.Japanese": "ja", 204 | "com.apple.keylayout.Chinese-Simplified": "zh", 205 | "com.apple.keylayout.PinyinKeyboard": "zh", 206 | "com.apple.keylayout.Chinese-Traditional": "zh", 207 | "com.apple.keylayout.Ukrainian": "uk", 208 | "com.apple.keylayout.Croatian": "hr", 209 | "com.apple.keylayout.Serbian": "sr", 210 | "com.apple.keylayout.Bulgarian": "bg", 211 | "com.apple.keylayout.Catalan": "ca", 212 | "com.apple.keylayout.Icelandic": "is", 213 | "com.apple.inputmethod.Korean.2SetKorean": "ko", 214 | "com.apple.inputmethod.SCIM.ITABC": "zh", 215 | "com.apple.inputmethod.TCIM.Cangjie": "zh", 216 | "com.apple.inputmethod.TYIM.Hiragana": "ja", 217 | "com.apple.inputmethod.TYIM.Katakana": "ja", 218 | "com.apple.inputmethod.Vietnamese.VietnameseIM": "vi", 219 | ] 220 | 221 | public static func languageCodeFromKeyboardIdentifier(_ identifier: String) -> String? { 222 | if let directMatch = keyboardIdentifierToLanguageCode[identifier] { 223 | return directMatch 224 | } 225 | 226 | for (key, value) in keyboardIdentifierToLanguageCode { 227 | if identifier.contains(key) || key.contains(identifier) { 228 | return value 229 | } 230 | } 231 | 232 | return nil 233 | } 234 | } 235 | 236 | extension MaterialStyle { 237 | init(rawValue: String) { 238 | switch rawValue { 239 | case "Ultra Thin": self = .ultraThin 240 | case "Thin": self = .thin 241 | case "Regular": self = .regular 242 | case "Thick": self = .thick 243 | case "Ultra Thick": self = .ultraThick 244 | default: self = .thin 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /RecordingIndicator.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | class RecordingIndicatorWindow: NSWindow { 5 | init() { 6 | super.init( 7 | contentRect: NSRect(x: 0, y: 0, width: 60, height: 60), 8 | styleMask: [.borderless], 9 | backing: .buffered, 10 | defer: false 11 | ) 12 | 13 | self.level = .floating 14 | self.isOpaque = false 15 | self.backgroundColor = .clear 16 | self.hasShadow = false 17 | self.isMovable = false 18 | self.ignoresMouseEvents = true 19 | 20 | let hostingView = NSHostingView(rootView: RecordingIndicatorView()) 21 | self.contentView = hostingView 22 | } 23 | 24 | func showNearCaret() { 25 | print("📍 showNearCaret called") 26 | 27 | // Try to get the active text field/view insertion point 28 | let caretPosition = getCaretPosition() 29 | 30 | // If we can't find the caret, don't show the indicator 31 | if caretPosition == NSPoint.zero { 32 | print("❌ Could not find caret position - not showing indicator") 33 | return 34 | } else { 35 | print("✅ Using caret position: \(caretPosition)") 36 | } 37 | 38 | // Position the window precisely at the caret 39 | let windowFrame = NSRect( 40 | x: caretPosition.x - 30, 41 | y: caretPosition.y - 30, 42 | width: 60, 43 | height: 60 44 | ) 45 | 46 | print("🪟 Setting window frame: \(windowFrame)") 47 | self.setFrame(windowFrame, display: true) 48 | self.orderFront(nil) 49 | 50 | // Animate in 51 | self.alphaValue = 0 52 | NSAnimationContext.runAnimationGroup { context in 53 | context.duration = 0.3 54 | context.allowsImplicitAnimation = true 55 | self.animator().alphaValue = 1.0 56 | } 57 | } 58 | 59 | private func getCaretPosition() -> NSPoint { 60 | print("🔍 Getting caret position using native-only detection...") 61 | 62 | // Check if we have accessibility permissions 63 | let trusted = AXIsProcessTrusted() 64 | if !trusted { 65 | print("❌ App doesn't have accessibility permissions!") 66 | let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] 67 | let trustedWithPrompt = AXIsProcessTrustedWithOptions(options as CFDictionary) 68 | print("🔐 Requested accessibility permissions: \(trustedWithPrompt)") 69 | return NSPoint.zero 70 | } 71 | 72 | print("✅ App has accessibility permissions") 73 | 74 | // Only try exact caret position method 75 | 76 | // Get exact caret position using focused element 77 | if let position = tryDirectFocusedElementMethod() { 78 | return position 79 | } 80 | 81 | print("❌ Native caret detection failed - not showing indicator") 82 | return NSPoint.zero 83 | } 84 | private func tryDirectFocusedElementMethod() -> NSPoint? { 85 | print("🎯 Trying direct focused element method...") 86 | 87 | let system = AXUIElementCreateSystemWide() 88 | var application: CFTypeRef? 89 | var focusedElement: CFTypeRef? 90 | 91 | // Step 1: Find the currently focused application 92 | guard 93 | AXUIElementCopyAttributeValue( 94 | system, kAXFocusedApplicationAttribute as CFString, &application) == .success 95 | else { 96 | print("❌ Could not get focused application") 97 | return nil 98 | } 99 | 100 | // Step 2: Find the currently focused UI Element in that application 101 | guard 102 | AXUIElementCopyAttributeValue( 103 | application! as! AXUIElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) 104 | == .success 105 | else { 106 | print("❌ Could not get focused UI element") 107 | return nil 108 | } 109 | 110 | return getCaretFromElement(focusedElement! as! AXUIElement) 111 | } 112 | 113 | private func getCaretFromElement(_ element: AXUIElement) -> NSPoint? { 114 | // Check if element has selection range attribute 115 | var rangeValueRef: CFTypeRef? 116 | guard 117 | AXUIElementCopyAttributeValue( 118 | element, kAXSelectedTextRangeAttribute as CFString, &rangeValueRef) == .success 119 | else { 120 | return nil 121 | } 122 | 123 | let rangeValue = rangeValueRef! as! AXValue 124 | var cfRange = CFRange() 125 | guard AXValueGetValue(rangeValue, .cfRange, &cfRange) else { 126 | return nil 127 | } 128 | 129 | // Get screen bounds for the cursor position 130 | var bounds: CFTypeRef? 131 | guard 132 | AXUIElementCopyParameterizedAttributeValue( 133 | element, kAXBoundsForRangeParameterizedAttribute as CFString, rangeValue, &bounds) 134 | == .success 135 | else { 136 | return nil 137 | } 138 | 139 | var screenRect = CGRect.zero 140 | guard AXValueGetValue(bounds! as! AXValue, .cgRect, &screenRect) else { 141 | return nil 142 | } 143 | 144 | return carbonToCocoa(carbonPoint: NSPoint(x: screenRect.origin.x, y: screenRect.origin.y)) 145 | } 146 | 147 | private func carbonToCocoa(carbonPoint: NSPoint) -> NSPoint { 148 | // Convert Carbon screen coordinates to Cocoa screen coordinates 149 | guard let mainScreen = NSScreen.main else { 150 | return carbonPoint 151 | } 152 | let screenHeight = mainScreen.frame.size.height 153 | return NSPoint(x: carbonPoint.x, y: screenHeight - carbonPoint.y) 154 | } 155 | 156 | func hide() { 157 | NSAnimationContext.runAnimationGroup({ context in 158 | context.duration = 0.3 159 | context.allowsImplicitAnimation = true 160 | self.animator().alphaValue = 0.0 161 | }) { 162 | self.orderOut(nil) 163 | } 164 | } 165 | } 166 | 167 | struct RecordingIndicatorView: View { 168 | @State private var pulseAnimation: Bool = false 169 | @State private var waveScale: CGFloat = 1.0 170 | 171 | var body: some View { 172 | ZStack { 173 | // Outer pulse ring 174 | Circle() 175 | .stroke(.red.opacity(0.3), lineWidth: 2) 176 | .frame(width: 40, height: 40) 177 | .scaleEffect(waveScale) 178 | .opacity(0.8) 179 | 180 | // Background circle 181 | Circle() 182 | .fill(.red.opacity(0.8)) 183 | .frame(width: 32, height: 32) 184 | .scaleEffect(pulseAnimation ? 1.05 : 1.0) 185 | 186 | // Main microphone icon with sound waves 187 | HStack(spacing: 2) { 188 | // Sound wave lines 189 | VStack(spacing: 2) { 190 | Rectangle() 191 | .fill(.white) 192 | .frame(width: 2, height: pulseAnimation ? 8 : 4) 193 | Rectangle() 194 | .fill(.white) 195 | .frame(width: 2, height: pulseAnimation ? 12 : 6) 196 | Rectangle() 197 | .fill(.white) 198 | .frame(width: 2, height: pulseAnimation ? 6 : 3) 199 | } 200 | .opacity(0.8) 201 | 202 | // Microphone icon 203 | Image(systemName: "mic.fill") 204 | .font(.system(size: 14, weight: .medium)) 205 | .foregroundColor(.white) 206 | } 207 | } 208 | .frame(width: 60, height: 60) 209 | .onAppear { 210 | startListeningAnimation() 211 | } 212 | } 213 | 214 | private func startListeningAnimation() { 215 | // Gentle pulse animation 216 | withAnimation( 217 | .easeInOut(duration: 1.2) 218 | .repeatForever(autoreverses: true) 219 | ) { 220 | pulseAnimation = true 221 | } 222 | 223 | // Subtle wave pulse 224 | withAnimation( 225 | .easeInOut(duration: 1.8) 226 | .repeatForever(autoreverses: true) 227 | ) { 228 | waveScale = 1.3 229 | } 230 | } 231 | } 232 | 233 | @MainActor 234 | class RecordingIndicatorManager: ObservableObject { 235 | private var indicatorWindow: RecordingIndicatorWindow? 236 | 237 | func showIndicator() { 238 | hideIndicator() 239 | indicatorWindow = RecordingIndicatorWindow() 240 | indicatorWindow?.showNearCaret() 241 | } 242 | 243 | func hideIndicator() { 244 | indicatorWindow?.hide() 245 | indicatorWindow = nil 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /Logger/LogManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogManager.swift 3 | // Whispera 4 | // 5 | // Created on 8/1/25. 6 | // 7 | import Foundation 8 | import os.log 9 | 10 | class LogManager { 11 | static let shared = LogManager() 12 | 13 | private let dateFormatter: DateFormatter = { 14 | let formatter = DateFormatter() 15 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" 16 | return formatter 17 | }() 18 | 19 | private let fileManager = FileManager.default 20 | private let maxLogFileSize: Int64 = 10 * 1024 * 1024 // 10MB 21 | 22 | private var logFileHandle: FileHandle? 23 | private let logQueue = DispatchQueue(label: "com.whispera.logging", qos: .utility) 24 | 25 | var logsDirectory: URL? { 26 | guard 27 | let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) 28 | .first 29 | else { 30 | return nil 31 | } 32 | return appSupport.appendingPathComponent("Whispera/Logs") 33 | } 34 | 35 | var currentLogFile: URL? { 36 | guard let logsDir = logsDirectory else { return nil } 37 | let fileName = 38 | "whispera-\(DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .none).replacingOccurrences(of: "/", with: "-")).log" 39 | return logsDir.appendingPathComponent(fileName) 40 | } 41 | 42 | private init() { 43 | setupLogsDirectory() 44 | rotateLogsIfNeeded() 45 | setupCrashHandlers() 46 | } 47 | 48 | private func setupLogsDirectory() { 49 | guard let logsDir = logsDirectory else { return } 50 | 51 | do { 52 | try fileManager.createDirectory(at: logsDir, withIntermediateDirectories: true) 53 | } catch { 54 | print("Failed to create logs directory: \(error)") 55 | } 56 | } 57 | 58 | private func rotateLogsIfNeeded() { 59 | guard let logFile = currentLogFile else { return } 60 | 61 | do { 62 | if fileManager.fileExists(atPath: logFile.path) { 63 | let attributes = try fileManager.attributesOfItem(atPath: logFile.path) 64 | if let fileSize = attributes[.size] as? Int64, fileSize > maxLogFileSize { 65 | // Archive the current log file 66 | let archiveName = 67 | logFile.deletingPathExtension().lastPathComponent 68 | + "-\(Date().timeIntervalSince1970).log" 69 | let archiveURL = logFile.deletingLastPathComponent().appendingPathComponent(archiveName) 70 | try fileManager.moveItem(at: logFile, to: archiveURL) 71 | } 72 | } 73 | } catch { 74 | print("Failed to rotate logs: \(error)") 75 | } 76 | } 77 | 78 | func writeLog(category: String, level: OSLogType, message: String) { 79 | guard UserDefaults.standard.bool(forKey: "enableExtendedLogging") else { 80 | return 81 | } 82 | 83 | // Check if we should log based on level 84 | let debugMode = UserDefaults.standard.bool(forKey: "enableDebugLogging") 85 | if !debugMode && level == .debug { 86 | return // Skip debug logs when debug mode is off 87 | } 88 | 89 | logQueue.async { [weak self] in 90 | guard let self = self else { return } 91 | 92 | self.rotateLogsIfNeeded() 93 | 94 | let timestamp = self.dateFormatter.string(from: Date()) 95 | let levelString = self.logLevelString(for: level) 96 | let logEntry = "[\(timestamp)] [\(levelString)] [\(category)] \(message)\n" 97 | 98 | guard let logFile = self.currentLogFile, 99 | let data = logEntry.data(using: .utf8) 100 | else { return } 101 | 102 | do { 103 | if !self.fileManager.fileExists(atPath: logFile.path) { 104 | self.fileManager.createFile(atPath: logFile.path, contents: nil) 105 | } 106 | 107 | if self.logFileHandle == nil { 108 | self.logFileHandle = try FileHandle(forWritingTo: logFile) 109 | self.logFileHandle?.seekToEndOfFile() 110 | } 111 | 112 | self.logFileHandle?.write(data) 113 | 114 | #if DEBUG 115 | // Force flush in debug mode for immediate visibility 116 | self.logFileHandle?.synchronizeFile() 117 | #endif 118 | } catch { 119 | print("Failed to write log: \(error)") 120 | self.logFileHandle = nil 121 | } 122 | } 123 | } 124 | 125 | private func logLevelString(for level: OSLogType) -> String { 126 | switch level { 127 | case .debug: return "DEBUG" 128 | case .info: return "INFO" 129 | case .error: return "ERROR" 130 | case .fault: return "FAULT" 131 | default: return "DEFAULT" 132 | } 133 | } 134 | 135 | func closeLogFile() { 136 | logQueue.sync { 137 | logFileHandle?.closeFile() 138 | logFileHandle = nil 139 | } 140 | } 141 | 142 | func getLogFiles() -> [URL] { 143 | guard let logsDir = logsDirectory else { return [] } 144 | 145 | do { 146 | let files = try fileManager.contentsOfDirectory( 147 | at: logsDir, includingPropertiesForKeys: [.fileSizeKey]) 148 | return files.filter { $0.pathExtension == "log" }.sorted { 149 | $0.lastPathComponent > $1.lastPathComponent 150 | } 151 | } catch { 152 | return [] 153 | } 154 | } 155 | 156 | func calculateLogsSize() -> Int64 { 157 | guard let logsDir = logsDirectory else { return 0 } 158 | 159 | var totalSize: Int64 = 0 160 | 161 | do { 162 | let files = try fileManager.contentsOfDirectory( 163 | at: logsDir, includingPropertiesForKeys: [.fileSizeKey]) 164 | 165 | for file in files where file.pathExtension == "log" { 166 | let attributes = try file.resourceValues(forKeys: [.fileSizeKey]) 167 | if let fileSize = attributes.fileSize { 168 | totalSize += Int64(fileSize) 169 | } 170 | } 171 | } catch { 172 | print("Failed to calculate logs size: \(error)") 173 | } 174 | 175 | return totalSize 176 | } 177 | 178 | func clearAllLogs() throws { 179 | closeLogFile() 180 | 181 | guard let logsDir = logsDirectory else { return } 182 | 183 | let files = try fileManager.contentsOfDirectory(at: logsDir, includingPropertiesForKeys: nil) 184 | for file in files where file.pathExtension == "log" { 185 | try fileManager.removeItem(at: file) 186 | } 187 | } 188 | 189 | private func setupCrashHandlers() { 190 | NSSetUncaughtExceptionHandler { exception in 191 | let crashInfo = """ 192 | === EXCEPTION CRASH REPORT === 193 | Exception Name: \(exception.name.rawValue) 194 | Reason: \(exception.reason ?? "Unknown") 195 | 196 | Call Stack: 197 | \(exception.callStackSymbols.joined(separator: "\n")) 198 | 199 | User Info: 200 | \(exception.userInfo ?? [:]) 201 | =================== 202 | """ 203 | 204 | LogManager.writeCrashLog(crashInfo) 205 | } 206 | 207 | let crashHandler: @convention(c) (Int32) -> Void = { signal in 208 | var message: [CChar] 209 | switch signal { 210 | case SIGABRT: message = Array("CRASH: SIGABRT\n".utf8CString) 211 | case SIGSEGV: message = Array("CRASH: SIGSEGV\n".utf8CString) 212 | case SIGBUS: message = Array("CRASH: SIGBUS\n".utf8CString) 213 | case SIGILL: message = Array("CRASH: SIGILL\n".utf8CString) 214 | case SIGFPE: message = Array("CRASH: SIGFPE\n".utf8CString) 215 | case SIGTRAP: message = Array("CRASH: SIGTRAP\n".utf8CString) 216 | default: message = Array("CRASH: UNKNOWN\n".utf8CString) 217 | } 218 | 219 | write(STDERR_FILENO, message, message.count - 1) 220 | _exit(1) 221 | } 222 | 223 | signal(SIGABRT, crashHandler) 224 | signal(SIGSEGV, crashHandler) 225 | signal(SIGBUS, crashHandler) 226 | signal(SIGILL, crashHandler) 227 | signal(SIGFPE, crashHandler) 228 | signal(SIGTRAP, crashHandler) 229 | } 230 | 231 | private static func writeCrashLog(_ crashInfo: String) { 232 | let logEntry = "[ERROR] [CrashHandler] 💥 CRASH: \(crashInfo)\n" 233 | 234 | fputs(logEntry, stderr) 235 | fflush(stderr) 236 | 237 | if let logFile = LogManager.shared.currentLogFile, 238 | let data = logEntry.data(using: .utf8) 239 | { 240 | do { 241 | if !FileManager.default.fileExists(atPath: logFile.path) { 242 | FileManager.default.createFile(atPath: logFile.path, contents: nil) 243 | } 244 | let handle = try FileHandle(forWritingTo: logFile) 245 | handle.seekToEndOfFile() 246 | handle.write(data) 247 | handle.closeFile() 248 | } catch { 249 | } 250 | } 251 | } 252 | 253 | deinit { 254 | closeLogFile() 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Onboarding/ModelSelectionView.swift: -------------------------------------------------------------------------------- 1 | import Hub 2 | // 3 | // ModelSelectionView.swift 4 | // Whispera 5 | // 6 | // Created by Varkhuman Mac on 7/4/25. 7 | // 8 | import SwiftUI 9 | 10 | struct ModelSelectionStepView: View { 11 | @Binding var selectedModel: String 12 | @Bindable var audioManager: AudioManager 13 | 14 | @State private var availableModels: [String] = [] 15 | @State private var isLoadingModels = false 16 | @State private var loadingError: String? 17 | @State private var errorMessage: String? 18 | @State private var showingError = false 19 | 20 | var body: some View { 21 | VStack(spacing: 24) { 22 | VStack(spacing: 16) { 23 | Image(systemName: "brain.head.profile") 24 | .font(.system(size: 48)) 25 | .foregroundColor(.blue) 26 | 27 | Text("Choose Whisper Model") 28 | .font(.system(.title, design: .rounded, weight: .semibold)) 29 | 30 | Text( 31 | "Select the Whisper model that best fits your needs. You can change this later in Settings." 32 | ) 33 | .font(.body) 34 | .foregroundColor(.secondary) 35 | .multilineTextAlignment(.center) 36 | } 37 | 38 | VStack(spacing: 16) { 39 | HStack { 40 | Text("AI Model") 41 | .font(.headline) 42 | Spacer() 43 | 44 | if isLoadingModels || audioManager.whisperKitTranscriber.isModelLoading { 45 | HStack(spacing: 8) { 46 | ProgressView() 47 | .scaleEffect(0.8) 48 | Text(getModelStatusText()) 49 | .font(.caption) 50 | .foregroundColor(.secondary) 51 | } 52 | } else { 53 | VStack(alignment: .trailing, spacing: 4) { 54 | Picker("Model", selection: $selectedModel) { 55 | ForEach(getModelOptions(), id: \.0) { model in 56 | Text(model.1).tag(model.0) 57 | } 58 | } 59 | .frame(minWidth: 220) 60 | 61 | if needsModelLoad { 62 | Button("Load Model") { 63 | Task { 64 | do { 65 | try await audioManager.whisperKitTranscriber 66 | .switchModel(to: selectedModel) 67 | } catch { 68 | await MainActor.run { 69 | errorMessage = 70 | "Failed to load model: \(error.localizedDescription)" 71 | showingError = true 72 | } 73 | } 74 | } 75 | } 76 | .buttonStyle(.borderedProminent) 77 | .controlSize(.small) 78 | } 79 | } 80 | } 81 | } 82 | 83 | Text( 84 | "Choose your Whisper model: base is fast and accurate for most use cases, small provides better accuracy for complex speech, and tiny is fastest for simple transcriptions." 85 | ) 86 | .font(.caption) 87 | .foregroundColor(.secondary) 88 | .multilineTextAlignment(.leading) 89 | 90 | if let error = loadingError { 91 | Text("Error loading models: \(error)") 92 | .font(.caption) 93 | .foregroundColor(.red) 94 | .multilineTextAlignment(.center) 95 | } 96 | 97 | if audioManager.whisperKitTranscriber.isDownloadingModel { 98 | VStack(spacing: 8) { 99 | HStack(spacing: 8) { 100 | ProgressView() 101 | .scaleEffect(0.8) 102 | Text( 103 | "Downloading \(audioManager.whisperKitTranscriber.downloadingModelName ?? "model")..." 104 | ) 105 | .font(.caption) 106 | .foregroundColor(.blue) 107 | } 108 | 109 | ProgressView(value: audioManager.whisperKitTranscriber.downloadProgress) 110 | .frame(height: 4) 111 | } 112 | .padding() 113 | .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) 114 | } 115 | 116 | Text( 117 | "Models are downloaded once and stored locally. Your voice data never leaves your Mac." 118 | ) 119 | .font(.caption) 120 | .foregroundColor(.secondary) 121 | .multilineTextAlignment(.center) 122 | } 123 | } 124 | .onAppear { 125 | loadAvailableModels() 126 | } 127 | .onChange(of: selectedModel) { _, newModel in 128 | downloadModelIfNeeded(newModel) 129 | } 130 | .alert("Error", isPresented: $showingError) { 131 | Button("OK") { 132 | showingError = false 133 | errorMessage = nil 134 | } 135 | } message: { 136 | Text(errorMessage ?? "An unknown error occurred") 137 | } 138 | } 139 | 140 | private var needsModelLoad: Bool { 141 | guard !selectedModel.isEmpty else { return false } 142 | guard audioManager.whisperKitTranscriber.isInitialized else { return false } 143 | guard 144 | !audioManager.whisperKitTranscriber.isDownloadingModel 145 | && !audioManager.whisperKitTranscriber.isModelLoading 146 | else { return false } 147 | 148 | // Check if selected model is different from currently loaded model 149 | return selectedModel != audioManager.whisperKitTranscriber.currentModel 150 | } 151 | 152 | private func getModelStatusText() -> String { 153 | if isLoadingModels { 154 | return "Loading models..." 155 | } else if audioManager.whisperKitTranscriber.isModelLoading { 156 | return "Loading \(selectedModel)..." 157 | } 158 | return "" 159 | } 160 | 161 | private func getModelOptions() -> [(String, String)] { 162 | if availableModels.isEmpty { 163 | return [("loading", "Loading models...")] 164 | } 165 | 166 | return availableModels.compactMap { model in 167 | let displayName = WhisperKitTranscriber.getModelDisplayName(for: model) 168 | return (model, displayName) 169 | } 170 | } 171 | 172 | private func loadAvailableModels() { 173 | isLoadingModels = true 174 | loadingError = nil 175 | 176 | Task { 177 | do { 178 | // Use WhisperKitTranscriber to fetch available models 179 | try await audioManager.whisperKitTranscriber.refreshAvailableModels() 180 | let fetchedModels = audioManager.whisperKitTranscriber.availableModels 181 | 182 | await MainActor.run { 183 | self.availableModels = fetchedModels.sorted { lhs, rhs in 184 | WhisperKitTranscriber.getModelPriority(for: lhs) 185 | < WhisperKitTranscriber.getModelPriority(for: rhs) 186 | } 187 | self.isLoadingModels = false 188 | 189 | // Set default selection if none set or invalid 190 | if selectedModel.isEmpty || !fetchedModels.contains(selectedModel) { 191 | // Find the first small multilingual model (preferred) or fallback to first available 192 | if let smallModel = fetchedModels.first(where: { 193 | $0.contains("small") && !$0.contains(".en") 194 | }) { 195 | selectedModel = smallModel 196 | } else if let firstModel = fetchedModels.first { 197 | selectedModel = firstModel 198 | } else { 199 | selectedModel = "openai_whisper-small" 200 | } 201 | } 202 | } 203 | } catch { 204 | await MainActor.run { 205 | self.loadingError = error.localizedDescription 206 | self.errorMessage = "Failed to load available models: \(error.localizedDescription)" 207 | self.showingError = true 208 | self.isLoadingModels = false 209 | // Use fallback models 210 | self.availableModels = [ 211 | "openai_whisper-tiny.en", 212 | "openai_whisper-base.en", 213 | "openai_whisper-small.en", 214 | ] 215 | if selectedModel.isEmpty { 216 | if let smallModel = self.availableModels.first(where: { 217 | $0.contains("small") && !$0.contains(".en") 218 | }) { 219 | selectedModel = smallModel 220 | } else if let firstModel = self.availableModels.first { 221 | selectedModel = firstModel 222 | } else { 223 | selectedModel = "openai_whisper-small" 224 | } 225 | } 226 | } 227 | } 228 | } 229 | } 230 | 231 | private func downloadModelIfNeeded(_ modelId: String) { 232 | // Only download if not already downloaded and not currently downloading 233 | guard 234 | !audioManager.whisperKitTranscriber.downloadedModels.contains(modelId) 235 | && !audioManager.whisperKitTranscriber.isDownloadingModel 236 | else { 237 | return // Already downloaded or downloading 238 | } 239 | 240 | Task { 241 | do { 242 | try await audioManager.whisperKitTranscriber.downloadModel(modelId) 243 | } catch { 244 | await MainActor.run { 245 | loadingError = "Failed to download model: \(error.localizedDescription)" 246 | errorMessage = "Failed to download model: \(error.localizedDescription)" 247 | showingError = true 248 | } 249 | } 250 | } 251 | } 252 | } 253 | --------------------------------------------------------------------------------