├── .cursor └── rules │ ├── composable-architecture-tree-navigation.mdc │ └── hex-overview.mdc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── README.md │ ├── build-and-release.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .swiftlint.yml ├── CLAUDE.md ├── Hex.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Hex.xcscheme ├── Hex ├── App │ ├── CheckForUpdatesView.swift │ ├── HexApp.swift │ └── HexAppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── appstore1024.png │ │ ├── mac1024.png │ │ ├── mac128.png │ │ ├── mac16.png │ │ ├── mac256.png │ │ ├── mac32.png │ │ ├── mac512.png │ │ └── mac64.png │ ├── Contents.json │ └── HexIcon.imageset │ │ ├── Contents.json │ │ ├── hex 1.svg │ │ └── hex 2.svg ├── Clients │ ├── KeyEventMonitorClient.swift │ ├── PasteboardClient.swift │ ├── RecordingClient.swift │ ├── SoundEffect.swift │ └── TranscriptionClient.swift ├── Features │ ├── App │ │ └── AppFeature.swift │ ├── History │ │ └── HistoryFeature.swift │ ├── Settings │ │ ├── AboutView.swift │ │ ├── ChangelogView.swift │ │ ├── HotKeyView.swift │ │ ├── ModelDownloadFeature.swift │ │ ├── SettingsFeature.swift │ │ └── SettingsView.swift │ └── Transcription │ │ ├── HotKeyProcessor.swift │ │ ├── TranscriptionFeature.swift │ │ └── TranscriptionIndicatorView.swift ├── Hex.entitlements ├── Info.plist ├── Models │ ├── HexSettings.swift │ ├── HotKey.swift │ └── Language.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Resources │ ├── Audio │ │ ├── cancel.mp3 │ │ ├── pasteTranscript.mp3 │ │ ├── startRecording.mp3 │ │ └── stopRecording.mp3 │ ├── Data │ │ ├── languages.json │ │ └── models.json │ └── changelog.md └── Views │ └── InvisibleWindow.swift ├── HexTests └── HexTests.swift ├── Localizable.xcstrings └── README.md /.cursor/rules/hex-overview.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | --- 5 | # Hex Overview 6 | 7 | Hex is a macOS app that allows you to transcribe your voice into text with a global hotkey. 8 | Hex will paste the transcription into your current app. 9 | 10 | 11 | - Swift Composable Architecture 12 | - Swift 6 13 | - Swift Async/Await -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kitlangton 2 | 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Version [e.g. 22] 28 | 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows for Hex 2 | 3 | This directory contains the CI/CD workflows for the Hex project. 4 | 5 | ## Workflows 6 | 7 | ### 1. CI (`ci.yml`) 8 | - **Trigger**: On every push to main and pull requests 9 | - **Purpose**: Continuous integration for code quality 10 | - **Jobs**: 11 | - Swift linting with SwiftLint 12 | - Build and test in both Debug and Release configurations 13 | - Caches Swift Package Manager dependencies 14 | 15 | ### 2. Build and Release (`build-and-release.yml`) 16 | - **Trigger**: On push to main and on version tags (v*) 17 | - **Purpose**: Build, test, and create releases 18 | - **Jobs**: 19 | - Build and test the app 20 | - Create release artifacts when a tag is pushed 21 | - Generate DMG installer 22 | - Create GitHub release with changelog 23 | 24 | ### 3. Manual Release (`release.yml`) 25 | - **Trigger**: Manual workflow dispatch 26 | - **Purpose**: Create signed and notarized releases 27 | - **Inputs**: 28 | - Version number (e.g., 0.2.4) 29 | - Build number (e.g., 37) 30 | - **Features**: 31 | - Code signing and notarization 32 | - DMG creation 33 | - Sparkle appcast update support 34 | 35 | ## Required Secrets 36 | 37 | For the release workflows to work properly, you need to configure these secrets in your GitHub repository: 38 | 39 | ### For Code Signing (release.yml) 40 | - `MACOS_CERTIFICATE`: Base64 encoded .p12 certificate 41 | - `MACOS_CERTIFICATE_PWD`: Password for the certificate 42 | - `KEYCHAIN_PWD`: Password for the temporary keychain 43 | - `DEVELOPMENT_TEAM`: Your Apple Developer Team ID (QC99C9JE59) 44 | 45 | ### For Notarization (release.yml) 46 | - `APPLE_ID`: Your Apple ID email 47 | - `APPLE_ID_PASSWORD`: App-specific password for notarization 48 | - `TEAM_ID`: Your Apple Team ID 49 | 50 | ### For Sparkle Updates (optional) 51 | - `AWS_ACCESS_KEY_ID`: For uploading to S3 52 | - `AWS_SECRET_ACCESS_KEY`: For uploading to S3 53 | - `SPARKLE_PRIVATE_KEY`: For signing Sparkle updates 54 | 55 | ## Usage 56 | 57 | ### Creating a Release 58 | 59 | 1. **Using Tags** (Recommended for releases): 60 | ```bash 61 | git tag v0.2.4 62 | git push origin v0.2.4 63 | ``` 64 | This will trigger the build-and-release workflow. 65 | 66 | 2. **Manual Release** (For signed/notarized releases): 67 | - Go to Actions → Release → Run workflow 68 | - Enter version and build numbers 69 | - The workflow will handle signing, notarization, and release creation 70 | 71 | ### Setting Up Secrets 72 | 73 | 1. Go to Settings → Secrets and variables → Actions 74 | 2. Add each required secret 75 | 76 | To create the certificate secret: 77 | ```bash 78 | # Export your Developer ID certificate from Keychain Access as .p12 79 | # Then convert to base64: 80 | base64 -i certificate.p12 | pbcopy 81 | ``` 82 | 83 | ### Sparkle Integration 84 | 85 | The workflows include placeholders for Sparkle appcast updates. To enable: 86 | 87 | 1. Set up your S3 bucket for hosting updates 88 | 2. Configure AWS credentials as secrets 89 | 3. Implement the appcast update logic in the workflow 90 | 91 | ## Notes 92 | 93 | - The CI workflow runs on every push and PR for quick feedback 94 | - Release builds are only created for version tags or manual triggers 95 | - All builds target macOS 15+ and Apple Silicon 96 | - SwiftLint is configured but set to continue on error to avoid blocking PRs -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: [ main ] 10 | 11 | env: 12 | XCODE_VERSION: '16.2' 13 | MACOS_VERSION: '15' 14 | 15 | jobs: 16 | build-and-test: 17 | name: Build and Test 18 | runs-on: macos-15 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Select Xcode 25 | run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app 26 | 27 | - name: Show Xcode version 28 | run: xcodebuild -version 29 | 30 | - name: Resolve dependencies 31 | run: | 32 | xcodebuild -resolvePackageDependencies \ 33 | -project Hex.xcodeproj \ 34 | -scheme Hex 35 | 36 | - name: Build for testing 37 | run: | 38 | xcodebuild build-for-testing \ 39 | -project Hex.xcodeproj \ 40 | -scheme Hex \ 41 | -configuration Debug \ 42 | -destination 'platform=macOS,arch=arm64' \ 43 | -derivedDataPath build/DerivedData \ 44 | CODE_SIGN_IDENTITY="" \ 45 | CODE_SIGNING_REQUIRED=NO \ 46 | CODE_SIGNING_ALLOWED=NO 47 | 48 | - name: Run tests 49 | run: | 50 | xcodebuild test-without-building \ 51 | -project Hex.xcodeproj \ 52 | -scheme Hex \ 53 | -configuration Debug \ 54 | -destination 'platform=macOS,arch=arm64' \ 55 | -derivedDataPath build/DerivedData \ 56 | -resultBundlePath build/TestResults.xcresult 57 | 58 | - name: Upload test results 59 | uses: actions/upload-artifact@v4 60 | if: failure() 61 | with: 62 | name: test-results 63 | path: build/TestResults.xcresult 64 | 65 | - name: Build Release 66 | run: | 67 | xcodebuild clean build \ 68 | -project Hex.xcodeproj \ 69 | -scheme Hex \ 70 | -configuration Release \ 71 | -derivedDataPath build/DerivedData \ 72 | -destination 'platform=macOS,arch=arm64' \ 73 | CODE_SIGN_IDENTITY="" \ 74 | CODE_SIGNING_REQUIRED=NO \ 75 | CODE_SIGNING_ALLOWED=NO \ 76 | ONLY_ACTIVE_ARCH=NO 77 | 78 | - name: Create build artifact 79 | run: | 80 | cd build/DerivedData/Build/Products/Release 81 | zip -r Hex.zip Hex.app 82 | 83 | - name: Upload build artifact 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: hex-app 87 | path: build/DerivedData/Build/Products/Release/Hex.zip 88 | 89 | release: 90 | name: Create Release 91 | needs: build-and-test 92 | runs-on: macos-15 93 | if: startsWith(github.ref, 'refs/tags/v') 94 | 95 | steps: 96 | - name: Checkout code 97 | uses: actions/checkout@v4 98 | 99 | - name: Download build artifact 100 | uses: actions/download-artifact@v4 101 | with: 102 | name: hex-app 103 | 104 | - name: Select Xcode 105 | run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app 106 | 107 | - name: Install create-dmg 108 | run: brew install create-dmg 109 | 110 | - name: Prepare for DMG 111 | run: | 112 | unzip Hex.zip 113 | mkdir -p dmg-content 114 | cp -R Hex.app dmg-content/ 115 | 116 | - name: Create DMG 117 | run: | 118 | create-dmg \ 119 | --volname "Hex Installer" \ 120 | --volicon "Hex.app/Contents/Resources/AppIcon.icns" \ 121 | --window-pos 200 120 \ 122 | --window-size 600 400 \ 123 | --icon-size 100 \ 124 | --icon "Hex.app" 150 185 \ 125 | --hide-extension "Hex.app" \ 126 | --app-drop-link 450 185 \ 127 | --no-internet-enable \ 128 | "Hex-${{ github.ref_name }}.dmg" \ 129 | "dmg-content/" 130 | 131 | - name: Generate changelog 132 | id: changelog 133 | run: | 134 | # Extract version number from tag 135 | VERSION="${{ github.ref_name }}" 136 | 137 | # Read changelog content for this version 138 | if [ -f "Hex/Resources/changelog.md" ]; then 139 | # Extract content between version headers 140 | CHANGELOG=$(awk "/^## $VERSION/{flag=1; next} /^## v[0-9]/{flag=0} flag" Hex/Resources/changelog.md) 141 | 142 | # If no specific version found, use the top section 143 | if [ -z "$CHANGELOG" ]; then 144 | CHANGELOG=$(awk '/^## /{if(++count==2) exit} count==1{if(!/^## /) print}' Hex/Resources/changelog.md) 145 | fi 146 | 147 | # Store in GitHub output 148 | echo "content<> $GITHUB_OUTPUT 149 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 150 | echo "EOF" >> $GITHUB_OUTPUT 151 | else 152 | echo "content=No changelog available for this release." >> $GITHUB_OUTPUT 153 | fi 154 | 155 | - name: Create GitHub Release 156 | uses: softprops/action-gh-release@v2 157 | with: 158 | name: Hex ${{ github.ref_name }} 159 | body: | 160 | ## What's New in ${{ github.ref_name }} 161 | 162 | ${{ steps.changelog.outputs.content }} 163 | 164 | --- 165 | 166 | ### Installation 167 | 168 | 1. Download `Hex-${{ github.ref_name }}.dmg` 169 | 2. Open the DMG file 170 | 3. Drag Hex.app to your Applications folder 171 | 4. Launch Hex from Applications 172 | 173 | ### Requirements 174 | 175 | - macOS 15.0 or later 176 | - Apple Silicon Mac (M1 or later) 177 | 178 | ### Notes 179 | 180 | - First launch requires granting microphone and accessibility permissions 181 | - Models will be downloaded on first use 182 | files: | 183 | Hex-${{ github.ref_name }}.dmg 184 | Hex.zip 185 | draft: false 186 | prerelease: false 187 | 188 | - name: Upload Release Stats 189 | run: | 190 | echo "Release ${{ github.ref_name }} created successfully" 191 | echo "DMG: Hex-${{ github.ref_name }}.dmg" 192 | echo "ZIP: Hex.zip" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | XCODE_VERSION: '16.2' 11 | 12 | jobs: 13 | lint: 14 | name: Swift Lint 15 | runs-on: macos-15 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Install SwiftLint 22 | run: brew install swiftlint 23 | 24 | - name: Run SwiftLint 25 | run: swiftlint --config .swiftlint.yml --reporter github-actions-logging 26 | continue-on-error: true 27 | 28 | build-test: 29 | name: Build and Test 30 | runs-on: macos-15 31 | strategy: 32 | matrix: 33 | configuration: [Debug, Release] 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Select Xcode 40 | run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app 41 | 42 | - name: Cache Swift Package Manager 43 | uses: actions/cache@v4 44 | with: 45 | path: | 46 | ~/Library/Developer/Xcode/DerivedData/**/SourcePackages 47 | ~/Library/Caches/org.swift.swiftpm 48 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 49 | restore-keys: | 50 | ${{ runner.os }}-spm- 51 | 52 | - name: Resolve dependencies 53 | run: | 54 | xcodebuild -resolvePackageDependencies \ 55 | -project Hex.xcodeproj \ 56 | -scheme Hex \ 57 | -clonedSourcePackagesDirPath SourcePackages 58 | 59 | - name: Build 60 | run: | 61 | xcodebuild clean build \ 62 | -project Hex.xcodeproj \ 63 | -scheme Hex \ 64 | -configuration ${{ matrix.configuration }} \ 65 | -destination 'platform=macOS,arch=arm64' \ 66 | -clonedSourcePackagesDirPath SourcePackages \ 67 | CODE_SIGN_IDENTITY="" \ 68 | CODE_SIGNING_REQUIRED=NO \ 69 | CODE_SIGNING_ALLOWED=NO \ 70 | ONLY_ACTIVE_ARCH=NO \ 71 | | xcbeautify 72 | 73 | - name: Run tests 74 | run: | 75 | xcodebuild test \ 76 | -project Hex.xcodeproj \ 77 | -scheme Hex \ 78 | -configuration ${{ matrix.configuration }} \ 79 | -destination 'platform=macOS,arch=arm64' \ 80 | -clonedSourcePackagesDirPath SourcePackages \ 81 | -resultBundlePath TestResults-${{ matrix.configuration }}.xcresult \ 82 | | xcbeautify 83 | 84 | - name: Upload test results 85 | uses: actions/upload-artifact@v4 86 | if: failure() 87 | with: 88 | name: test-results-${{ matrix.configuration }} 89 | path: TestResults-${{ matrix.configuration }}.xcresult -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version number (e.g., 0.2.4)' 8 | required: true 9 | type: string 10 | build_number: 11 | description: 'Build number (e.g., 37)' 12 | required: true 13 | type: string 14 | 15 | jobs: 16 | create-release: 17 | name: Build and Create Release 18 | runs-on: macos-15 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Select Xcode 25 | run: sudo xcode-select -s /Applications/Xcode_16.2.app 26 | 27 | - name: Update version numbers 28 | run: | 29 | # Update Info.plist 30 | /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${{ inputs.version }}" Hex/Info.plist 31 | /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${{ inputs.build_number }}" Hex/Info.plist 32 | 33 | # Update project.pbxproj 34 | sed -i '' "s/MARKETING_VERSION = .*;/MARKETING_VERSION = ${{ inputs.version }};/g" Hex.xcodeproj/project.pbxproj 35 | sed -i '' "s/CURRENT_PROJECT_VERSION = .*;/CURRENT_PROJECT_VERSION = ${{ inputs.build_number }};/g" Hex.xcodeproj/project.pbxproj 36 | 37 | - name: Install dependencies 38 | run: | 39 | brew install create-dmg 40 | brew install gh 41 | 42 | - name: Setup signing 43 | env: 44 | MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} 45 | MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} 46 | KEYCHAIN_PWD: ${{ secrets.KEYCHAIN_PWD }} 47 | run: | 48 | # Create temporary keychain 49 | security create-keychain -p "$KEYCHAIN_PWD" build.keychain 50 | security default-keychain -s build.keychain 51 | security unlock-keychain -p "$KEYCHAIN_PWD" build.keychain 52 | 53 | # Import certificate 54 | echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 55 | security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign 56 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" build.keychain 57 | 58 | # Verify 59 | security find-identity -v -p codesigning 60 | 61 | - name: Resolve dependencies 62 | run: | 63 | xcodebuild -resolvePackageDependencies \ 64 | -project Hex.xcodeproj \ 65 | -scheme Hex 66 | 67 | - name: Build and Archive 68 | run: | 69 | xcodebuild clean archive \ 70 | -project Hex.xcodeproj \ 71 | -scheme Hex \ 72 | -configuration Release \ 73 | -archivePath build/Hex.xcarchive \ 74 | -destination 'platform=macOS,arch=arm64' \ 75 | CODE_SIGN_IDENTITY="Apple Development" \ 76 | DEVELOPMENT_TEAM=${{ secrets.DEVELOPMENT_TEAM }} \ 77 | ONLY_ACTIVE_ARCH=NO 78 | 79 | - name: Export Archive 80 | run: | 81 | xcodebuild -exportArchive \ 82 | -archivePath build/Hex.xcarchive \ 83 | -exportPath build/export \ 84 | -exportOptionsPlist ExportOptions.plist 85 | 86 | - name: Create DMG 87 | run: | 88 | cd build/export 89 | create-dmg \ 90 | --volname "Hex ${{ inputs.version }}" \ 91 | --volicon "Hex.app/Contents/Resources/AppIcon.icns" \ 92 | --window-pos 200 120 \ 93 | --window-size 600 400 \ 94 | --icon-size 100 \ 95 | --icon "Hex.app" 150 185 \ 96 | --hide-extension "Hex.app" \ 97 | --app-drop-link 450 185 \ 98 | --no-internet-enable \ 99 | --hdiutil-quiet \ 100 | "Hex-v${{ inputs.version }}.dmg" \ 101 | "Hex.app" 102 | 103 | - name: Notarize DMG 104 | env: 105 | APPLE_ID: ${{ secrets.APPLE_ID }} 106 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 107 | TEAM_ID: ${{ secrets.TEAM_ID }} 108 | run: | 109 | cd build/export 110 | 111 | # Submit for notarization 112 | xcrun notarytool submit "Hex-v${{ inputs.version }}.dmg" \ 113 | --apple-id "$APPLE_ID" \ 114 | --password "$APPLE_ID_PASSWORD" \ 115 | --team-id "$TEAM_ID" \ 116 | --wait 117 | 118 | # Staple the notarization 119 | xcrun stapler staple "Hex-v${{ inputs.version }}.dmg" 120 | 121 | - name: Create Sparkle appcast entry 122 | run: | 123 | cd build/export 124 | 125 | # Generate Sparkle signature 126 | # This requires Sparkle's generate_appcast tool 127 | # You'll need to set up Sparkle's private key as a secret 128 | 129 | - name: Create ZIP archive 130 | run: | 131 | cd build/export 132 | zip -r "Hex-v${{ inputs.version }}.zip" Hex.app 133 | 134 | - name: Generate changelog 135 | id: changelog 136 | run: | 137 | VERSION="v${{ inputs.version }}" 138 | 139 | # Read changelog content for this version 140 | if [ -f "Hex/Resources/changelog.md" ]; then 141 | CHANGELOG=$(awk "/^## $VERSION/{flag=1; next} /^## v[0-9]/{flag=0} flag" Hex/Resources/changelog.md) 142 | 143 | if [ -z "$CHANGELOG" ]; then 144 | CHANGELOG="- Various improvements and bug fixes" 145 | fi 146 | 147 | echo "content<> $GITHUB_OUTPUT 148 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 149 | echo "EOF" >> $GITHUB_OUTPUT 150 | else 151 | echo "content=- Initial release" >> $GITHUB_OUTPUT 152 | fi 153 | 154 | - name: Create GitHub Release 155 | env: 156 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 157 | run: | 158 | gh release create "v${{ inputs.version }}" \ 159 | --title "Hex v${{ inputs.version }}" \ 160 | --notes "## What's New in v${{ inputs.version }} 161 | 162 | ${{ steps.changelog.outputs.content }} 163 | 164 | ### Installation 165 | 166 | 1. Download \`Hex-v${{ inputs.version }}.dmg\` 167 | 2. Open the DMG file 168 | 3. Drag Hex.app to your Applications folder 169 | 4. Launch Hex from Applications 170 | 171 | ### Requirements 172 | 173 | - macOS 15.0 or later 174 | - Apple Silicon Mac (M1 or later) 175 | 176 | ### Verification 177 | 178 | This release is signed and notarized by Apple." \ 179 | "build/export/Hex-v${{ inputs.version }}.dmg" \ 180 | "build/export/Hex-v${{ inputs.version }}.zip" 181 | 182 | - name: Update Sparkle appcast 183 | env: 184 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 185 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 186 | run: | 187 | # This step would update your Sparkle appcast XML on S3 188 | # You'll need to implement this based on your Sparkle setup 189 | echo "TODO: Update Sparkle appcast" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | updates/ 3 | .vscode 4 | .DS_Store 5 | 6 | # Xcode 7 | *.xcodeproj/xcuserdata/ 8 | *.xcworkspace/xcuserdata/ 9 | *.xcuserstate 10 | buildServer.json 11 | bin 12 | scripts 13 | ExportOptions.plist 14 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # SwiftLint configuration for Hex 2 | 3 | disabled_rules: 4 | - trailing_whitespace 5 | - line_length 6 | - force_cast 7 | - identifier_name 8 | - type_name 9 | 10 | opt_in_rules: 11 | - empty_count 12 | - closure_spacing 13 | - collection_alignment 14 | - contains_over_first_not_nil 15 | - empty_string 16 | - first_where 17 | - force_unwrapping 18 | - implicitly_unwrapped_optional 19 | - last_where 20 | - multiline_function_chains 21 | - multiline_parameters 22 | - operator_usage_whitespace 23 | - overridden_super_call 24 | - prefer_self_type_over_type_of_self 25 | - redundant_nil_coalescing 26 | - sorted_first_last 27 | - trailing_closure 28 | - unneeded_parentheses_in_closure_argument 29 | - vertical_parameter_alignment_on_call 30 | - yoda_condition 31 | 32 | excluded: 33 | - build 34 | - .build 35 | - SourcePackages 36 | - DerivedData 37 | - .swiftpm 38 | - Hex.xcodeproj 39 | - HexTests 40 | 41 | line_length: 42 | warning: 150 43 | error: 200 44 | ignores_function_declarations: true 45 | ignores_comments: true 46 | ignores_urls: true 47 | 48 | function_body_length: 49 | warning: 60 50 | error: 100 51 | 52 | file_length: 53 | warning: 500 54 | error: 1000 55 | 56 | type_body_length: 57 | warning: 300 58 | error: 500 59 | 60 | large_tuple: 61 | warning: 3 62 | error: 4 63 | 64 | function_parameter_count: 65 | warning: 6 66 | error: 8 67 | 68 | cyclomatic_complexity: 69 | warning: 15 70 | error: 20 71 | 72 | nesting: 73 | type_level: 74 | warning: 2 75 | function_level: 76 | warning: 3 77 | 78 | custom_rules: 79 | tca_reducer_protocol: 80 | name: "TCA Reducer Protocol" 81 | regex: "struct\\s+\\w+:\\s*Reducer" 82 | message: "Consider using ReducerProtocol for TCA reducers" 83 | severity: warning -------------------------------------------------------------------------------- /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 | Hex is a macOS menu bar application that provides AI-powered voice-to-text transcription using OpenAI's Whisper models. Users activate transcription via customizable hotkeys, and the transcribed text can be automatically pasted into the active application. 8 | 9 | ## Build & Development Commands 10 | 11 | ```bash 12 | # Build the app 13 | xcodebuild -scheme Hex -configuration Release 14 | 15 | # Run tests 16 | xcodebuild test -scheme Hex 17 | 18 | # Open in Xcode (recommended for development) 19 | open Hex.xcodeproj 20 | ``` 21 | 22 | ## Architecture 23 | 24 | The app uses **The Composable Architecture (TCA)** for state management. Key architectural components: 25 | 26 | ### Features (TCA Reducers) 27 | - `AppFeature`: Root feature coordinating the app lifecycle 28 | - `TranscriptionFeature`: Core recording and transcription logic 29 | - `SettingsFeature`: User preferences and configuration 30 | - `HistoryFeature`: Transcription history management 31 | 32 | ### Dependency Clients 33 | - `TranscriptionClient`: WhisperKit integration for ML transcription 34 | - `RecordingClient`: AVAudioRecorder wrapper for audio capture 35 | - `PasteboardClient`: Clipboard operations 36 | - `KeyEventMonitorClient`: Global hotkey monitoring via Sauce framework 37 | 38 | ### Key Dependencies 39 | - **WhisperKit**: Core ML transcription (tracking main branch) 40 | - **Sauce**: Keyboard event monitoring 41 | - **Sparkle**: Auto-updates (feed: https://hex-updates.s3.amazonaws.com/appcast.xml) 42 | - **Swift Composable Architecture**: State management 43 | - **Inject** Hot Reloading for SwiftUI 44 | 45 | ## Important Implementation Details 46 | 47 | 1. **Hotkey Recording Modes**: The app supports both press-and-hold and double-tap recording modes, implemented in `HotKeyProcessor.swift` 48 | 49 | 2. **Model Management**: Whisper models are downloaded on-demand via `ModelDownloadFeature`. Available models are defined in `Resources/Data/models.json` 50 | 51 | 3. **Sound Effects**: Audio feedback is provided via `SoundEffect.swift` using files in `Resources/Audio/` 52 | 53 | 4. **Window Management**: Uses an `InvisibleWindow` for the transcription indicator overlay 54 | 55 | 5. **Permissions**: Requires audio input and automation entitlements (see `Hex.entitlements`) 56 | 57 | ## Testing 58 | 59 | Tests use Swift Testing framework. The main test file is `HexTests/HexTests.swift`. Run tests via Xcode or the command line. -------------------------------------------------------------------------------- /Hex.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Hex.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "a5a88b74d69e39b900a0dd06629fcd7925239845ee887b203dc356c440532881", 3 | "pins" : [ 4 | { 5 | "identity" : "combine-schedulers", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/combine-schedulers", 8 | "state" : { 9 | "revision" : "5928286acce13def418ec36d05a001a9641086f2", 10 | "version" : "1.0.3" 11 | } 12 | }, 13 | { 14 | "identity" : "inject", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/krzysztofzablocki/Inject", 17 | "state" : { 18 | "revision" : "728c56639ecb3df441d51d5bc6747329afabcfc9", 19 | "version" : "1.5.2" 20 | } 21 | }, 22 | { 23 | "identity" : "jinja", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/johnmai-dev/Jinja", 26 | "state" : { 27 | "revision" : "8879db488805122463b6521486d4c0a557fb56dc", 28 | "version" : "1.2.0" 29 | } 30 | }, 31 | { 32 | "identity" : "networkimage", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/gonzalezreal/NetworkImage", 35 | "state" : { 36 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 37 | "version" : "6.0.1" 38 | } 39 | }, 40 | { 41 | "identity" : "pow", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/EmergeTools/Pow", 44 | "state" : { 45 | "revision" : "a504eb6d144bcf49f4f33029a2795345cb39e6b4", 46 | "version" : "1.0.5" 47 | } 48 | }, 49 | { 50 | "identity" : "sauce", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/Clipy/Sauce", 53 | "state" : { 54 | "branch" : "master", 55 | "revision" : "9ed4ca442cdd4be20449479b4e8f157ea96e7542" 56 | } 57 | }, 58 | { 59 | "identity" : "sparkle", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/sparkle-project/Sparkle", 62 | "state" : { 63 | "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", 64 | "version" : "2.7.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-argument-parser", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-argument-parser.git", 71 | "state" : { 72 | "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", 73 | "version" : "1.5.1" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-case-paths", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/swift-case-paths", 80 | "state" : { 81 | "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", 82 | "version" : "1.7.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-clocks", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/pointfreeco/swift-clocks", 89 | "state" : { 90 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", 91 | "version" : "1.0.6" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-cmark", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/swiftlang/swift-cmark", 98 | "state" : { 99 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", 100 | "version" : "0.6.0" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-collections", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-collections", 107 | "state" : { 108 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 109 | "version" : "1.2.0" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-composable-architecture", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/pointfreeco/swift-composable-architecture", 116 | "state" : { 117 | "revision" : "6574de2396319a58e86e2178577268cb4aeccc30", 118 | "version" : "1.20.2" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-concurrency-extras", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 125 | "state" : { 126 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 127 | "version" : "1.3.1" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-custom-dump", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 134 | "state" : { 135 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 136 | "version" : "1.3.3" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-dependencies", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/pointfreeco/swift-dependencies", 143 | "state" : { 144 | "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", 145 | "version" : "1.9.2" 146 | } 147 | }, 148 | { 149 | "identity" : "swift-identified-collections", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 152 | "state" : { 153 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", 154 | "version" : "1.1.1" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-markdown-ui", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 161 | "state" : { 162 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 163 | "version" : "2.4.1" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-navigation", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/pointfreeco/swift-navigation", 170 | "state" : { 171 | "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", 172 | "version" : "2.3.0" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-perception", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/pointfreeco/swift-perception", 179 | "state" : { 180 | "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", 181 | "version" : "1.6.0" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-sharing", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/pointfreeco/swift-sharing", 188 | "state" : { 189 | "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", 190 | "version" : "2.5.2" 191 | } 192 | }, 193 | { 194 | "identity" : "swift-syntax", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/swiftlang/swift-syntax", 197 | "state" : { 198 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 199 | "version" : "601.0.1" 200 | } 201 | }, 202 | { 203 | "identity" : "swift-transformers", 204 | "kind" : "remoteSourceControl", 205 | "location" : "https://github.com/huggingface/swift-transformers.git", 206 | "state" : { 207 | "revision" : "8a83416cc00ab07a5de9991e6ad817a9b8588d20", 208 | "version" : "0.1.15" 209 | } 210 | }, 211 | { 212 | "identity" : "whisperkit", 213 | "kind" : "remoteSourceControl", 214 | "location" : "https://github.com/argmaxinc/WhisperKit", 215 | "state" : { 216 | "branch" : "main", 217 | "revision" : "cd16844c270b7e78bf21d24b1d9ff7bc88e904e4" 218 | } 219 | }, 220 | { 221 | "identity" : "xctest-dynamic-overlay", 222 | "kind" : "remoteSourceControl", 223 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 224 | "state" : { 225 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 226 | "version" : "1.5.2" 227 | } 228 | } 229 | ], 230 | "version" : 3 231 | } 232 | -------------------------------------------------------------------------------- /Hex.xcodeproj/xcshareddata/xcschemes/Hex.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 36 | 42 | 43 | 44 | 45 | 46 | 56 | 58 | 64 | 65 | 66 | 67 | 71 | 72 | 76 | 77 | 78 | 79 | 85 | 87 | 93 | 94 | 95 | 96 | 98 | 99 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /Hex/App/CheckForUpdatesView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ComposableArchitecture 3 | import Inject 4 | import Sparkle 5 | import SwiftUI 6 | 7 | @Observable 8 | @MainActor 9 | final class CheckForUpdatesViewModel { 10 | init() { 11 | anyCancellable = controller.updater.publisher(for: \.canCheckForUpdates) 12 | .sink(receiveValue: { self.canCheckForUpdates = $0 }) 13 | } 14 | 15 | static let shared = CheckForUpdatesViewModel() 16 | 17 | let controller = SPUStandardUpdaterController( 18 | startingUpdater: true, 19 | updaterDelegate: nil, 20 | userDriverDelegate: nil 21 | ) 22 | 23 | var anyCancellable: AnyCancellable? 24 | 25 | var canCheckForUpdates = false 26 | 27 | func checkForUpdates() { 28 | controller.updater.checkForUpdates() 29 | } 30 | } 31 | 32 | struct CheckForUpdatesView: View { 33 | @State var viewModel = CheckForUpdatesViewModel.shared 34 | @ObserveInjection var inject 35 | 36 | var body: some View { 37 | Button("Check for Updates…", action: viewModel.checkForUpdates) 38 | .disabled(!viewModel.canCheckForUpdates) 39 | .enableInjection() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Hex/App/HexApp.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Inject 3 | import Sparkle 4 | import SwiftUI 5 | 6 | @main 7 | struct HexApp: App { 8 | static let appStore = Store(initialState: AppFeature.State()) { 9 | AppFeature() 10 | } 11 | 12 | @NSApplicationDelegateAdaptor(HexAppDelegate.self) var appDelegate 13 | 14 | var body: some Scene { 15 | MenuBarExtra { 16 | CheckForUpdatesView() 17 | 18 | Button("Settings...") { 19 | appDelegate.presentSettingsView() 20 | }.keyboardShortcut(",") 21 | 22 | Divider() 23 | 24 | Button("Quit") { 25 | NSApplication.shared.terminate(nil) 26 | }.keyboardShortcut("q") 27 | } label: { 28 | let image: NSImage = { 29 | let ratio = $0.size.height / $0.size.width 30 | $0.size.height = 18 31 | $0.size.width = 18 / ratio 32 | return $0 33 | }(NSImage(named: "HexIcon")!) 34 | Image(nsImage: image) 35 | } 36 | 37 | 38 | WindowGroup {}.defaultLaunchBehavior(.suppressed) 39 | .commands { 40 | CommandGroup(after: .appInfo) { 41 | CheckForUpdatesView() 42 | 43 | Button("Settings...") { 44 | appDelegate.presentSettingsView() 45 | }.keyboardShortcut(",") 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Hex/App/HexAppDelegate.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | class HexAppDelegate: NSObject, NSApplicationDelegate { 5 | var invisibleWindow: InvisibleWindow? 6 | var settingsWindow: NSWindow? 7 | var statusItem: NSStatusItem! 8 | 9 | @Dependency(\.soundEffects) var soundEffect 10 | @Shared(.hexSettings) var hexSettings: HexSettings 11 | 12 | func applicationDidFinishLaunching(_: Notification) { 13 | if isTesting { 14 | print("TESTING") 15 | return 16 | } 17 | 18 | Task { 19 | await soundEffect.preloadSounds() 20 | } 21 | print("HexAppDelegate did finish launching") 22 | 23 | // Set activation policy first 24 | updateAppMode() 25 | 26 | // Add notification observer 27 | NotificationCenter.default.addObserver( 28 | self, 29 | selector: #selector(handleAppModeUpdate), 30 | name: NSNotification.Name("UpdateAppMode"), 31 | object: nil 32 | ) 33 | 34 | // Then present main views 35 | presentMainView() 36 | presentSettingsView() 37 | NSApp.activate(ignoringOtherApps: true) 38 | } 39 | 40 | func presentMainView() { 41 | guard invisibleWindow == nil else { 42 | return 43 | } 44 | let transcriptionStore = HexApp.appStore.scope(state: \.transcription, action: \.transcription) 45 | let transcriptionView = TranscriptionView(store: transcriptionStore).padding().padding(.top).padding(.top) 46 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 47 | invisibleWindow = InvisibleWindow.fromView(transcriptionView) 48 | invisibleWindow?.makeKeyAndOrderFront(nil) 49 | } 50 | 51 | func presentSettingsView() { 52 | if let settingsWindow = settingsWindow { 53 | settingsWindow.makeKeyAndOrderFront(nil) 54 | NSApp.activate(ignoringOtherApps: true) 55 | return 56 | } 57 | 58 | let settingsView = AppView(store: HexApp.appStore) 59 | let settingsWindow = NSWindow( 60 | contentRect: .init(x: 0, y: 0, width: 700, height: 700), 61 | styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable], 62 | backing: .buffered, 63 | defer: false 64 | ) 65 | settingsWindow.titleVisibility = .visible 66 | settingsWindow.contentView = NSHostingView(rootView: settingsView) 67 | settingsWindow.makeKeyAndOrderFront(nil) 68 | settingsWindow.isReleasedWhenClosed = false 69 | settingsWindow.center() 70 | settingsWindow.toolbarStyle = NSWindow.ToolbarStyle.unified 71 | NSApp.activate(ignoringOtherApps: true) 72 | self.settingsWindow = settingsWindow 73 | } 74 | 75 | @objc private func handleAppModeUpdate() { 76 | Task { 77 | await updateAppMode() 78 | } 79 | } 80 | 81 | @MainActor 82 | private func updateAppMode() { 83 | print("hexSettings.showDockIcon: \(hexSettings.showDockIcon)") 84 | if hexSettings.showDockIcon { 85 | NSApp.setActivationPolicy(.regular) 86 | } else { 87 | NSApp.setActivationPolicy(.accessory) 88 | } 89 | } 90 | 91 | func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { 92 | presentSettingsView() 93 | return true 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "appstore1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "filename" : "mac16.png", 11 | "idiom" : "mac", 12 | "scale" : "1x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "mac32.png", 17 | "idiom" : "mac", 18 | "scale" : "2x", 19 | "size" : "16x16" 20 | }, 21 | { 22 | "filename" : "mac32.png", 23 | "idiom" : "mac", 24 | "scale" : "1x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "mac64.png", 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "32x32" 32 | }, 33 | { 34 | "filename" : "mac128.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "mac256.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "128x128" 44 | }, 45 | { 46 | "filename" : "mac256.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "mac512.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "256x256" 56 | }, 57 | { 58 | "filename" : "mac512.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "512x512" 62 | }, 63 | { 64 | "filename" : "mac1024.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "512x512" 68 | } 69 | ], 70 | "info" : { 71 | "author" : "xcode", 72 | "version" : 1 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/appstore1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/appstore1024.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac1024.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac128.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac16.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac256.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac32.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac512.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/AppIcon.appiconset/mac64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Assets.xcassets/AppIcon.appiconset/mac64.png -------------------------------------------------------------------------------- /Hex/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/HexIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "hex 1.svg", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "hex 2.svg", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/HexIcon.imageset/hex 1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Hex/Assets.xcassets/HexIcon.imageset/hex 2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Hex/Clients/KeyEventMonitorClient.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Carbon 3 | import Dependencies 4 | import DependenciesMacros 5 | import Foundation 6 | import os 7 | import Sauce 8 | 9 | private let logger = Logger(subsystem: "com.kitlangton.Hex", category: "KeyEventMonitor") 10 | 11 | public struct KeyEvent { 12 | let key: Key? 13 | let modifiers: Modifiers 14 | } 15 | 16 | public extension KeyEvent { 17 | init(cgEvent: CGEvent, type _: CGEventType) { 18 | let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode)) 19 | let key = cgEvent.type == .keyDown ? Sauce.shared.key(for: keyCode) : nil 20 | 21 | let modifiers = Modifiers.from(carbonFlags: cgEvent.flags) 22 | self.init(key: key, modifiers: modifiers) 23 | } 24 | } 25 | 26 | @DependencyClient 27 | struct KeyEventMonitorClient { 28 | var listenForKeyPress: @Sendable () async -> AsyncThrowingStream = { .never } 29 | var handleKeyEvent: @Sendable (@escaping (KeyEvent) -> Bool) -> Void = { _ in } 30 | var startMonitoring: @Sendable () async -> Void = {} 31 | } 32 | 33 | extension KeyEventMonitorClient: DependencyKey { 34 | static var liveValue: KeyEventMonitorClient { 35 | let live = KeyEventMonitorClientLive() 36 | return KeyEventMonitorClient( 37 | listenForKeyPress: { 38 | live.listenForKeyPress() 39 | }, 40 | handleKeyEvent: { handler in 41 | live.handleKeyEvent(handler) 42 | }, 43 | startMonitoring: { 44 | live.startMonitoring() 45 | } 46 | ) 47 | } 48 | } 49 | 50 | extension DependencyValues { 51 | var keyEventMonitor: KeyEventMonitorClient { 52 | get { self[KeyEventMonitorClient.self] } 53 | set { self[KeyEventMonitorClient.self] = newValue } 54 | } 55 | } 56 | 57 | class KeyEventMonitorClientLive { 58 | private var eventTapPort: CFMachPort? 59 | private var runLoopSource: CFRunLoopSource? 60 | private var continuations: [UUID: (KeyEvent) -> Bool] = [:] 61 | private var isMonitoring = false 62 | 63 | init() { 64 | logger.info("Initializing HotKeyClient with CGEvent tap.") 65 | } 66 | 67 | deinit { 68 | self.stopMonitoring() 69 | } 70 | 71 | /// Provide a stream of key events. 72 | func listenForKeyPress() -> AsyncThrowingStream { 73 | AsyncThrowingStream { continuation in 74 | let uuid = UUID() 75 | continuations[uuid] = { event in 76 | continuation.yield(event) 77 | return false 78 | } 79 | 80 | // Start monitoring if this is the first subscription 81 | if continuations.count == 1 { 82 | startMonitoring() 83 | } 84 | 85 | // Cleanup on cancellation 86 | continuation.onTermination = { [weak self] _ in 87 | self?.removeContinuation(uuid: uuid) 88 | } 89 | } 90 | } 91 | 92 | private func removeContinuation(uuid: UUID) { 93 | continuations[uuid] = nil 94 | 95 | // Stop monitoring if no more listeners 96 | if continuations.isEmpty { 97 | stopMonitoring() 98 | } 99 | } 100 | 101 | func startMonitoring() { 102 | guard !isMonitoring else { return } 103 | isMonitoring = true 104 | 105 | // Create an event tap at the HID level to capture keyDown, keyUp, and flagsChanged 106 | let eventMask = 107 | ((1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)) 108 | 109 | guard 110 | let eventTap = CGEvent.tapCreate( 111 | tap: .cghidEventTap, 112 | place: .headInsertEventTap, 113 | options: .defaultTap, 114 | eventsOfInterest: CGEventMask(eventMask), 115 | callback: { _, type, cgEvent, userInfo in 116 | guard 117 | let hotKeyClientLive = Unmanaged 118 | .fromOpaque(userInfo!) 119 | .takeUnretainedValue() as KeyEventMonitorClientLive? 120 | else { 121 | return Unmanaged.passUnretained(cgEvent) 122 | } 123 | 124 | let keyEvent = KeyEvent(cgEvent: cgEvent, type: type) 125 | let handled = hotKeyClientLive.processKeyEvent(keyEvent) 126 | 127 | if handled { 128 | return nil 129 | } else { 130 | return Unmanaged.passUnretained(cgEvent) 131 | } 132 | }, 133 | userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) 134 | ) 135 | else { 136 | isMonitoring = false 137 | logger.error("Failed to create event tap.") 138 | return 139 | } 140 | 141 | eventTapPort = eventTap 142 | 143 | // Create a RunLoop source and add it to the current run loop 144 | let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 145 | self.runLoopSource = runLoopSource 146 | 147 | CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 148 | CGEvent.tapEnable(tap: eventTap, enable: true) 149 | 150 | logger.info("Started monitoring key events via CGEvent tap.") 151 | } 152 | 153 | // TODO: Handle removing the handler from the continuations on deinit/cancellation 154 | func handleKeyEvent(_ handler: @escaping (KeyEvent) -> Bool) { 155 | let uuid = UUID() 156 | continuations[uuid] = handler 157 | 158 | if continuations.count == 1 { 159 | startMonitoring() 160 | } 161 | } 162 | 163 | private func stopMonitoring() { 164 | guard isMonitoring else { return } 165 | isMonitoring = false 166 | 167 | if let runLoopSource = runLoopSource { 168 | CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) 169 | self.runLoopSource = nil 170 | } 171 | 172 | if let eventTapPort = eventTapPort { 173 | CGEvent.tapEnable(tap: eventTapPort, enable: false) 174 | self.eventTapPort = nil 175 | } 176 | 177 | logger.info("Stopped monitoring key events via CGEvent tap.") 178 | } 179 | 180 | private func processKeyEvent(_ keyEvent: KeyEvent) -> Bool { 181 | var handled = false 182 | 183 | for continuation in continuations.values { 184 | if continuation(keyEvent) { 185 | handled = true 186 | } 187 | } 188 | 189 | return handled 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Hex/Clients/PasteboardClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasteboardClient.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/24/25. 6 | // 7 | 8 | import ComposableArchitecture 9 | import Dependencies 10 | import DependenciesMacros 11 | import Sauce 12 | import SwiftUI 13 | 14 | @DependencyClient 15 | struct PasteboardClient { 16 | var paste: @Sendable (String) async -> Void 17 | var copy: @Sendable (String) async -> Void 18 | } 19 | 20 | extension PasteboardClient: DependencyKey { 21 | static var liveValue: Self { 22 | let live = PasteboardClientLive() 23 | return .init( 24 | paste: { text in 25 | await live.paste(text: text) 26 | }, 27 | copy: { text in 28 | await live.copy(text: text) 29 | } 30 | ) 31 | } 32 | } 33 | 34 | extension DependencyValues { 35 | var pasteboard: PasteboardClient { 36 | get { self[PasteboardClient.self] } 37 | set { self[PasteboardClient.self] = newValue } 38 | } 39 | } 40 | 41 | struct PasteboardClientLive { 42 | @Shared(.hexSettings) var hexSettings: HexSettings 43 | 44 | @MainActor 45 | func paste(text: String) async { 46 | if hexSettings.useClipboardPaste { 47 | await pasteWithClipboard(text) 48 | } else { 49 | simulateTypingWithAppleScript(text) 50 | } 51 | } 52 | 53 | @MainActor 54 | func copy(text: String) async { 55 | let pasteboard = NSPasteboard.general 56 | pasteboard.clearContents() 57 | pasteboard.setString(text, forType: .string) 58 | } 59 | 60 | // Function to save the current state of the NSPasteboard 61 | func savePasteboardState(pasteboard: NSPasteboard) -> [[String: Any]] { 62 | var savedItems: [[String: Any]] = [] 63 | 64 | for item in pasteboard.pasteboardItems ?? [] { 65 | var itemDict: [String: Any] = [:] 66 | for type in item.types { 67 | if let data = item.data(forType: type) { 68 | itemDict[type.rawValue] = data 69 | } 70 | } 71 | savedItems.append(itemDict) 72 | } 73 | 74 | return savedItems 75 | } 76 | 77 | // Function to restore the saved state of the NSPasteboard 78 | func restorePasteboardState(pasteboard: NSPasteboard, savedItems: [[String: Any]]) { 79 | pasteboard.clearContents() 80 | 81 | for itemDict in savedItems { 82 | let item = NSPasteboardItem() 83 | for (type, data) in itemDict { 84 | if let data = data as? Data { 85 | item.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type)) 86 | } 87 | } 88 | pasteboard.writeObjects([item]) 89 | } 90 | } 91 | 92 | /// Pastes current clipboard content to the frontmost application 93 | static func pasteToFrontmostApp() -> Bool { 94 | let script = """ 95 | tell application "System Events" 96 | tell process (name of first application process whose frontmost is true) 97 | tell (menu item "Paste" of menu of menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1) 98 | if exists then 99 | log (get properties of it) 100 | if enabled then 101 | click it 102 | return true 103 | else 104 | return false 105 | end if 106 | end if 107 | end tell 108 | tell (menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1) 109 | if exists then 110 | if enabled then 111 | click it 112 | return true 113 | else 114 | return false 115 | end if 116 | else 117 | return false 118 | end if 119 | end tell 120 | end tell 121 | end tell 122 | """ 123 | 124 | var error: NSDictionary? 125 | if let scriptObject = NSAppleScript(source: script) { 126 | let result = scriptObject.executeAndReturnError(&error) 127 | if let error = error { 128 | print("Error executing paste: \(error)") 129 | return false 130 | } 131 | return result.booleanValue 132 | } 133 | return false 134 | } 135 | 136 | func pasteWithClipboard(_ text: String) async { 137 | let pasteboard = NSPasteboard.general 138 | let originalItems = savePasteboardState(pasteboard: pasteboard) 139 | pasteboard.clearContents() 140 | pasteboard.setString(text, forType: .string) 141 | 142 | let source = CGEventSource(stateID: .combinedSessionState) 143 | 144 | // Track if paste operation successful 145 | var pasteSucceeded = PasteboardClientLive.pasteToFrontmostApp() 146 | 147 | // If menu-based paste failed, try simulated keypresses 148 | if !pasteSucceeded { 149 | print("Failed to paste to frontmost app, falling back to simulated keypresses") 150 | let vKeyCode = Sauce.shared.keyCode(for: .v) 151 | let cmdKeyCode: CGKeyCode = 55 // Command key 152 | 153 | // Create cmd down event 154 | let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: cmdKeyCode, keyDown: true) 155 | 156 | // Create v down event 157 | let vDown = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true) 158 | vDown?.flags = .maskCommand 159 | 160 | // Create v up event 161 | let vUp = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) 162 | vUp?.flags = .maskCommand 163 | 164 | // Create cmd up event 165 | let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: cmdKeyCode, keyDown: false) 166 | 167 | // Post the events 168 | cmdDown?.post(tap: .cghidEventTap) 169 | vDown?.post(tap: .cghidEventTap) 170 | vUp?.post(tap: .cghidEventTap) 171 | cmdUp?.post(tap: .cghidEventTap) 172 | 173 | // Assume keypress-based paste succeeded - but text will remain in clipboard as fallback 174 | pasteSucceeded = true 175 | } 176 | 177 | // Only restore original pasteboard contents if: 178 | // 1. Copying to clipboard is disabled AND 179 | // 2. The paste operation succeeded 180 | if !hexSettings.copyToClipboard && pasteSucceeded { 181 | try? await Task.sleep(for: .seconds(0.1)) 182 | pasteboard.clearContents() 183 | restorePasteboardState(pasteboard: pasteboard, savedItems: originalItems) 184 | } 185 | 186 | // If we failed to paste AND user doesn't want clipboard retention, 187 | // show a notification that text is available in clipboard 188 | if !pasteSucceeded && !hexSettings.copyToClipboard { 189 | // Keep the transcribed text in clipboard regardless of setting 190 | print("Paste operation failed. Text remains in clipboard as fallback.") 191 | 192 | // TODO: Could add a notification here to inform user 193 | // that text is available in clipboard 194 | } 195 | } 196 | 197 | func simulateTypingWithAppleScript(_ text: String) { 198 | let escapedText = text.replacingOccurrences(of: "\"", with: "\\\"") 199 | let script = NSAppleScript(source: "tell application \"System Events\" to keystroke \"\(escapedText)\"") 200 | var error: NSDictionary? 201 | script?.executeAndReturnError(&error) 202 | if let error = error { 203 | print("Error executing AppleScript: \(error)") 204 | } 205 | } 206 | 207 | enum PasteError: Error { 208 | case systemWideElementCreationFailed 209 | case focusedElementNotFound 210 | case elementDoesNotSupportTextEditing 211 | case failedToInsertText 212 | } 213 | 214 | static func insertTextAtCursor(_ text: String) throws { 215 | // Get the system-wide accessibility element 216 | let systemWideElement = AXUIElementCreateSystemWide() 217 | 218 | // Get the focused element 219 | var focusedElementRef: CFTypeRef? 220 | let axError = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedElementRef) 221 | 222 | guard axError == .success, let focusedElementRef = focusedElementRef else { 223 | throw PasteError.focusedElementNotFound 224 | } 225 | 226 | let focusedElement = focusedElementRef as! AXUIElement 227 | 228 | // Verify if the focused element supports text insertion 229 | var value: CFTypeRef? 230 | let supportsText = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute as CFString, &value) == .success 231 | let supportsSelectedText = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, &value) == .success 232 | 233 | if !supportsText && !supportsSelectedText { 234 | throw PasteError.elementDoesNotSupportTextEditing 235 | } 236 | 237 | // // Get any selected text 238 | // var selectedText: String = "" 239 | // if AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, &value) == .success, 240 | // let selectedValue = value as? String { 241 | // selectedText = selectedValue 242 | // } 243 | 244 | // print("selected text: \(selectedText)") 245 | 246 | // Insert text at cursor position by replacing selected text (or empty selection) 247 | let insertResult = AXUIElementSetAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, text as CFTypeRef) 248 | 249 | if insertResult != .success { 250 | throw PasteError.failedToInsertText 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /Hex/Clients/SoundEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoundEffect.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/26/25. 6 | // 7 | 8 | import AVFoundation 9 | import ComposableArchitecture 10 | import Dependencies 11 | import DependenciesMacros 12 | import Foundation 13 | import SwiftUI 14 | 15 | // Thank you. Never mind then.What a beautiful idea. 16 | public enum SoundEffect: String, CaseIterable { 17 | case pasteTranscript 18 | case startRecording 19 | case stopRecording 20 | case cancel 21 | 22 | public var fileName: String { 23 | self.rawValue 24 | } 25 | } 26 | 27 | @DependencyClient 28 | public struct SoundEffectsClient { 29 | public var play: @Sendable (SoundEffect) async -> Void 30 | public var stop: @Sendable (SoundEffect) async -> Void 31 | public var stopAll: @Sendable () async -> Void 32 | public var preloadSounds: @Sendable () async -> Void 33 | } 34 | 35 | extension SoundEffectsClient: DependencyKey { 36 | public static var liveValue: SoundEffectsClient { 37 | let live = SoundEffectsClientLive() 38 | return SoundEffectsClient( 39 | play: { soundEffect in 40 | await live.play(soundEffect) 41 | }, 42 | stop: { soundEffect in 43 | await live.stop(soundEffect) 44 | }, 45 | stopAll: { 46 | await live.stopAll() 47 | }, 48 | preloadSounds: { 49 | await live.preloadSounds() 50 | } 51 | ) 52 | } 53 | } 54 | 55 | public extension DependencyValues { 56 | var soundEffects: SoundEffectsClient { 57 | get { self[SoundEffectsClient.self] } 58 | set { self[SoundEffectsClient.self] = newValue } 59 | } 60 | } 61 | 62 | actor SoundEffectsClientLive { 63 | 64 | @Shared(.hexSettings) var hexSettings: HexSettings 65 | 66 | func play(_ soundEffect: SoundEffect) { 67 | guard hexSettings.soundEffectsEnabled else { return } 68 | guard let player = audioPlayers[soundEffect] else { 69 | print("Sound not found: \(soundEffect)") 70 | return 71 | } 72 | player.volume = 0.2 73 | player.currentTime = 0 74 | player.play() 75 | } 76 | 77 | func stop(_ soundEffect: SoundEffect) { 78 | audioPlayers[soundEffect]?.stop() 79 | } 80 | 81 | func stopAll() { 82 | audioPlayers.values.forEach { $0.stop() } 83 | } 84 | 85 | func preloadSounds() async { 86 | guard !isSetup else { return } 87 | 88 | for soundEffect in SoundEffect.allCases { 89 | loadSound(soundEffect) 90 | } 91 | 92 | isSetup = true 93 | } 94 | 95 | private var audioPlayers: [SoundEffect: AVAudioPlayer] = [:] 96 | private var isSetup = false 97 | 98 | private func loadSound(_ soundEffect: SoundEffect) { 99 | guard let url = Bundle.main.url( 100 | forResource: soundEffect.fileName, 101 | withExtension: "mp3" 102 | ) else { 103 | print("Failed to find sound file: \(soundEffect.fileName).mp3") 104 | return 105 | } 106 | 107 | do { 108 | let player = try AVAudioPlayer(contentsOf: url) 109 | player.prepareToPlay() 110 | audioPlayers[soundEffect] = player 111 | } catch { 112 | print("Failed to load sound \(soundEffect): \(error.localizedDescription)") 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Hex/Clients/TranscriptionClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TranscriptionClient.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/24/25. 6 | // 7 | 8 | import AVFoundation 9 | import Dependencies 10 | import DependenciesMacros 11 | import Foundation 12 | import WhisperKit 13 | 14 | /// A client that downloads and loads WhisperKit models, then transcribes audio files using the loaded model. 15 | /// Exposes progress callbacks to report overall download-and-load percentage and transcription progress. 16 | @DependencyClient 17 | struct TranscriptionClient { 18 | /// Transcribes an audio file at the specified `URL` using the named `model`. 19 | /// Reports transcription progress via `progressCallback`. 20 | var transcribe: @Sendable (URL, String, DecodingOptions, @escaping (Progress) -> Void) async throws -> String 21 | 22 | /// Ensures a model is downloaded (if missing) and loaded into memory, reporting progress via `progressCallback`. 23 | var downloadModel: @Sendable (String, @escaping (Progress) -> Void) async throws -> Void 24 | 25 | /// Deletes a model from disk if it exists 26 | var deleteModel: @Sendable (String) async throws -> Void 27 | 28 | /// Checks if a named model is already downloaded on this system. 29 | var isModelDownloaded: @Sendable (String) async -> Bool = { _ in false } 30 | 31 | /// Fetches a recommended set of models for the user's hardware from Hugging Face's `argmaxinc/whisperkit-coreml`. 32 | var getRecommendedModels: @Sendable () async throws -> ModelSupport 33 | 34 | /// Lists all model variants found in `argmaxinc/whisperkit-coreml`. 35 | var getAvailableModels: @Sendable () async throws -> [String] 36 | } 37 | 38 | extension TranscriptionClient: DependencyKey { 39 | static var liveValue: Self { 40 | let live = TranscriptionClientLive() 41 | return Self( 42 | transcribe: { try await live.transcribe(url: $0, model: $1, options: $2, progressCallback: $3) }, 43 | downloadModel: { try await live.downloadAndLoadModel(variant: $0, progressCallback: $1) }, 44 | deleteModel: { try await live.deleteModel(variant: $0) }, 45 | isModelDownloaded: { await live.isModelDownloaded($0) }, 46 | getRecommendedModels: { await live.getRecommendedModels() }, 47 | getAvailableModels: { try await live.getAvailableModels() } 48 | ) 49 | } 50 | } 51 | 52 | extension DependencyValues { 53 | var transcription: TranscriptionClient { 54 | get { self[TranscriptionClient.self] } 55 | set { self[TranscriptionClient.self] = newValue } 56 | } 57 | } 58 | 59 | /// An `actor` that manages WhisperKit models by downloading (from Hugging Face), 60 | // loading them into memory, and then performing transcriptions. 61 | 62 | actor TranscriptionClientLive { 63 | // MARK: - Stored Properties 64 | 65 | /// The current in-memory `WhisperKit` instance, if any. 66 | private var whisperKit: WhisperKit? 67 | 68 | /// The name of the currently loaded model, if any. 69 | private var currentModelName: String? 70 | 71 | /// The base folder under which we store model data (e.g., ~/Library/Application Support/...). 72 | private lazy var modelsBaseFolder: URL = { 73 | do { 74 | let appSupportURL = try FileManager.default.url( 75 | for: .applicationSupportDirectory, 76 | in: .userDomainMask, 77 | appropriateFor: nil, 78 | create: true 79 | ) 80 | // Typically: .../Application Support/com.kitlangton.Hex 81 | let ourAppFolder = appSupportURL.appendingPathComponent("com.kitlangton.Hex", isDirectory: true) 82 | // Inside there, store everything in /models 83 | let baseURL = ourAppFolder.appendingPathComponent("models", isDirectory: true) 84 | try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) 85 | return baseURL 86 | } catch { 87 | fatalError("Could not create Application Support folder: \(error)") 88 | } 89 | }() 90 | 91 | // MARK: - Public Methods 92 | 93 | /// Ensures the given `variant` model is downloaded and loaded, reporting 94 | /// overall progress (0%–50% for downloading, 50%–100% for loading). 95 | func downloadAndLoadModel(variant: String, progressCallback: @escaping (Progress) -> Void) async throws { 96 | // Special handling for corrupted or malformed variant names 97 | if variant.isEmpty { 98 | throw NSError( 99 | domain: "TranscriptionClient", 100 | code: -3, 101 | userInfo: [ 102 | NSLocalizedDescriptionKey: "Cannot download model: Empty model name" 103 | ] 104 | ) 105 | } 106 | 107 | let overallProgress = Progress(totalUnitCount: 100) 108 | overallProgress.completedUnitCount = 0 109 | progressCallback(overallProgress) 110 | 111 | print("[TranscriptionClientLive] Processing model: \(variant)") 112 | 113 | // 1) Model download phase (0-50% progress) 114 | if !(await isModelDownloaded(variant)) { 115 | try await downloadModelIfNeeded(variant: variant) { downloadProgress in 116 | let fraction = downloadProgress.fractionCompleted * 0.5 117 | overallProgress.completedUnitCount = Int64(fraction * 100) 118 | progressCallback(overallProgress) 119 | } 120 | } else { 121 | // Skip download phase if already downloaded 122 | overallProgress.completedUnitCount = 50 123 | progressCallback(overallProgress) 124 | } 125 | 126 | // 2) Model loading phase (50-100% progress) 127 | try await loadWhisperKitModel(variant) { loadingProgress in 128 | let fraction = 0.5 + (loadingProgress.fractionCompleted * 0.5) 129 | overallProgress.completedUnitCount = Int64(fraction * 100) 130 | progressCallback(overallProgress) 131 | } 132 | 133 | // Final progress update 134 | overallProgress.completedUnitCount = 100 135 | progressCallback(overallProgress) 136 | } 137 | 138 | /// Deletes a model from disk if it exists 139 | func deleteModel(variant: String) async throws { 140 | let modelFolder = modelPath(for: variant) 141 | 142 | // Check if the model exists 143 | guard FileManager.default.fileExists(atPath: modelFolder.path) else { 144 | // Model doesn't exist, nothing to delete 145 | return 146 | } 147 | 148 | // If this is the currently loaded model, unload it first 149 | if currentModelName == variant { 150 | unloadCurrentModel() 151 | } 152 | 153 | // Delete the model directory 154 | try FileManager.default.removeItem(at: modelFolder) 155 | 156 | print("[TranscriptionClientLive] Deleted model: \(variant)") 157 | } 158 | 159 | /// Returns `true` if the model is already downloaded to the local folder. 160 | /// Performs a thorough check to ensure the model files are actually present and usable. 161 | func isModelDownloaded(_ modelName: String) async -> Bool { 162 | let modelFolderPath = modelPath(for: modelName).path 163 | let fileManager = FileManager.default 164 | 165 | // First, check if the basic model directory exists 166 | guard fileManager.fileExists(atPath: modelFolderPath) else { 167 | // Don't print logs that would spam the console 168 | return false 169 | } 170 | 171 | do { 172 | // Check if the directory has actual model files in it 173 | let contents = try fileManager.contentsOfDirectory(atPath: modelFolderPath) 174 | 175 | // Model should have multiple files and certain key components 176 | guard !contents.isEmpty else { 177 | return false 178 | } 179 | 180 | // Check for specific model structure - need both tokenizer and model files 181 | let hasModelFiles = contents.contains { $0.hasSuffix(".mlmodelc") || $0.contains("model") } 182 | let tokenizerFolderPath = tokenizerPath(for: modelName).path 183 | let hasTokenizer = fileManager.fileExists(atPath: tokenizerFolderPath) 184 | 185 | // Both conditions must be true for a model to be considered downloaded 186 | return hasModelFiles && hasTokenizer 187 | } catch { 188 | return false 189 | } 190 | } 191 | 192 | /// Returns a list of recommended models based on current device hardware. 193 | func getRecommendedModels() async -> ModelSupport { 194 | await WhisperKit.recommendedRemoteModels() 195 | } 196 | 197 | /// Lists all model variants available in the `argmaxinc/whisperkit-coreml` repository. 198 | func getAvailableModels() async throws -> [String] { 199 | try await WhisperKit.fetchAvailableModels() 200 | } 201 | 202 | /// Transcribes the audio file at `url` using a `model` name. 203 | /// If the model is not yet loaded (or if it differs from the current model), it is downloaded and loaded first. 204 | /// Transcription progress can be monitored via `progressCallback`. 205 | func transcribe( 206 | url: URL, 207 | model: String, 208 | options: DecodingOptions, 209 | progressCallback: @escaping (Progress) -> Void 210 | ) async throws -> String { 211 | // Load or switch to the required model if needed. 212 | if whisperKit == nil || model != currentModelName { 213 | unloadCurrentModel() 214 | try await downloadAndLoadModel(variant: model) { p in 215 | // Debug logging, or scale as desired: 216 | progressCallback(p) 217 | } 218 | } 219 | 220 | guard let whisperKit = whisperKit else { 221 | throw NSError( 222 | domain: "TranscriptionClient", 223 | code: -1, 224 | userInfo: [ 225 | NSLocalizedDescriptionKey: "Failed to initialize WhisperKit for model: \(model)", 226 | ] 227 | ) 228 | } 229 | 230 | // Perform the transcription. 231 | let results = try await whisperKit.transcribe(audioPath: url.path, decodeOptions: options) 232 | 233 | // Concatenate results from all segments. 234 | let text = results.map(\.text).joined(separator: " ") 235 | return text 236 | } 237 | 238 | // MARK: - Private Helpers 239 | 240 | /// Creates or returns the local folder (on disk) for a given `variant` model. 241 | private func modelPath(for variant: String) -> URL { 242 | // Remove any possible path traversal or invalid characters from variant name 243 | let sanitizedVariant = variant.components(separatedBy: CharacterSet(charactersIn: "./\\")).joined(separator: "_") 244 | 245 | return modelsBaseFolder 246 | .appendingPathComponent("argmaxinc") 247 | .appendingPathComponent("whisperkit-coreml") 248 | .appendingPathComponent(sanitizedVariant, isDirectory: true) 249 | } 250 | 251 | /// Creates or returns the local folder for the tokenizer files of a given `variant`. 252 | private func tokenizerPath(for variant: String) -> URL { 253 | modelPath(for: variant).appendingPathComponent("tokenizer", isDirectory: true) 254 | } 255 | 256 | // Unloads any currently loaded model (clears `whisperKit` and `currentModelName`). 257 | private func unloadCurrentModel() { 258 | whisperKit = nil 259 | currentModelName = nil 260 | } 261 | 262 | /// Downloads the model to a temporary folder (if it isn't already on disk), 263 | /// then moves it into its final folder in `modelsBaseFolder`. 264 | private func downloadModelIfNeeded( 265 | variant: String, 266 | progressCallback: @escaping (Progress) -> Void 267 | ) async throws { 268 | let modelFolder = modelPath(for: variant) 269 | 270 | // If the model folder exists but isn't a complete model, clean it up 271 | let isDownloaded = await isModelDownloaded(variant) 272 | if FileManager.default.fileExists(atPath: modelFolder.path) && !isDownloaded { 273 | try FileManager.default.removeItem(at: modelFolder) 274 | } 275 | 276 | // If model is already fully downloaded, we're done 277 | if isDownloaded { 278 | return 279 | } 280 | 281 | print("[TranscriptionClientLive] Downloading model: \(variant)") 282 | 283 | // Create parent directories 284 | let parentDir = modelFolder.deletingLastPathComponent() 285 | try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) 286 | 287 | do { 288 | // Download directly using the exact variant name provided 289 | let tempFolder = try await WhisperKit.download( 290 | variant: variant, 291 | downloadBase: nil, 292 | useBackgroundSession: false, 293 | from: "argmaxinc/whisperkit-coreml", 294 | token: nil, 295 | progressCallback: { progress in 296 | progressCallback(progress) 297 | } 298 | ) 299 | 300 | // Ensure target folder exists 301 | try FileManager.default.createDirectory(at: modelFolder, withIntermediateDirectories: true) 302 | 303 | // Move the downloaded snapshot to the final location 304 | try moveContents(of: tempFolder, to: modelFolder) 305 | 306 | print("[TranscriptionClientLive] Downloaded model to: \(modelFolder.path)") 307 | } catch { 308 | // Clean up any partial download if an error occurred 309 | if FileManager.default.fileExists(atPath: modelFolder.path) { 310 | try? FileManager.default.removeItem(at: modelFolder) 311 | } 312 | 313 | // Rethrow the original error 314 | print("[TranscriptionClientLive] Error downloading model: \(error.localizedDescription)") 315 | throw error 316 | } 317 | } 318 | 319 | /// Loads a local model folder via `WhisperKitConfig`, optionally reporting load progress. 320 | private func loadWhisperKitModel( 321 | _ modelName: String, 322 | progressCallback: @escaping (Progress) -> Void 323 | ) async throws { 324 | let loadingProgress = Progress(totalUnitCount: 100) 325 | loadingProgress.completedUnitCount = 0 326 | progressCallback(loadingProgress) 327 | 328 | let modelFolder = modelPath(for: modelName) 329 | let tokenizerFolder = tokenizerPath(for: modelName) 330 | 331 | // Use WhisperKit's config to load the model 332 | let config = WhisperKitConfig( 333 | model: modelName, 334 | modelFolder: modelFolder.path, 335 | tokenizerFolder: tokenizerFolder, 336 | // verbose: true, 337 | // logLevel: .debug, 338 | prewarm: true, 339 | load: true 340 | ) 341 | 342 | // The initializer automatically calls `loadModels`. 343 | whisperKit = try await WhisperKit(config) 344 | currentModelName = modelName 345 | 346 | // Finalize load progress 347 | loadingProgress.completedUnitCount = 100 348 | progressCallback(loadingProgress) 349 | 350 | print("[TranscriptionClientLive] Loaded WhisperKit model: \(modelName)") 351 | } 352 | 353 | /// Moves all items from `sourceFolder` into `destFolder` (shallow move of directory contents). 354 | private func moveContents(of sourceFolder: URL, to destFolder: URL) throws { 355 | let fileManager = FileManager.default 356 | let items = try fileManager.contentsOfDirectory(atPath: sourceFolder.path) 357 | for item in items { 358 | let src = sourceFolder.appendingPathComponent(item) 359 | let dst = destFolder.appendingPathComponent(item) 360 | try fileManager.moveItem(at: src, to: dst) 361 | } 362 | } 363 | } -------------------------------------------------------------------------------- /Hex/Features/App/AppFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppFeature.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/26/25. 6 | // 7 | 8 | import ComposableArchitecture 9 | import Dependencies 10 | import SwiftUI 11 | 12 | @Reducer 13 | struct AppFeature { 14 | enum ActiveTab: Equatable { 15 | case settings 16 | case history 17 | case about 18 | } 19 | 20 | @ObservableState 21 | struct State { 22 | var transcription: TranscriptionFeature.State = .init() 23 | var settings: SettingsFeature.State = .init() 24 | var history: HistoryFeature.State = .init() 25 | var activeTab: ActiveTab = .settings 26 | } 27 | 28 | enum Action: BindableAction { 29 | case binding(BindingAction) 30 | case transcription(TranscriptionFeature.Action) 31 | case settings(SettingsFeature.Action) 32 | case history(HistoryFeature.Action) 33 | case setActiveTab(ActiveTab) 34 | } 35 | 36 | var body: some ReducerOf { 37 | BindingReducer() 38 | 39 | Scope(state: \.transcription, action: \.transcription) { 40 | TranscriptionFeature() 41 | } 42 | 43 | Scope(state: \.settings, action: \.settings) { 44 | SettingsFeature() 45 | } 46 | 47 | Scope(state: \.history, action: \.history) { 48 | HistoryFeature() 49 | } 50 | 51 | Reduce { state, action in 52 | switch action { 53 | case .binding: 54 | return .none 55 | case .transcription: 56 | return .none 57 | case .settings: 58 | return .none 59 | case .history(.navigateToSettings): 60 | state.activeTab = .settings 61 | return .none 62 | case .history: 63 | return .none 64 | case let .setActiveTab(tab): 65 | state.activeTab = tab 66 | return .none 67 | } 68 | } 69 | } 70 | } 71 | 72 | struct AppView: View { 73 | @Bindable var store: StoreOf 74 | @State private var columnVisibility = NavigationSplitViewVisibility.automatic 75 | 76 | var body: some View { 77 | NavigationSplitView(columnVisibility: $columnVisibility) { 78 | List(selection: $store.activeTab) { 79 | Button { 80 | store.send(.setActiveTab(.settings)) 81 | } label: { 82 | Label("Settings", systemImage: "gearshape") 83 | }.buttonStyle(.plain) 84 | .tag(AppFeature.ActiveTab.settings) 85 | 86 | Button { 87 | store.send(.setActiveTab(.history)) 88 | } label: { 89 | Label("History", systemImage: "clock") 90 | }.buttonStyle(.plain) 91 | .tag(AppFeature.ActiveTab.history) 92 | 93 | Button { 94 | store.send(.setActiveTab(.about)) 95 | } label: { 96 | Label("About", systemImage: "info.circle") 97 | }.buttonStyle(.plain) 98 | .tag(AppFeature.ActiveTab.about) 99 | } 100 | } detail: { 101 | switch store.state.activeTab { 102 | case .settings: 103 | SettingsView(store: store.scope(state: \.settings, action: \.settings)) 104 | .navigationTitle("Settings") 105 | case .history: 106 | HistoryView(store: store.scope(state: \.history, action: \.history)) 107 | .navigationTitle("History") 108 | case .about: 109 | AboutView(store: store.scope(state: \.settings, action: \.settings)) 110 | .navigationTitle("About") 111 | } 112 | } 113 | .enableInjection() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Hex/Features/History/HistoryFeature.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import ComposableArchitecture 3 | import Dependencies 4 | import SwiftUI 5 | import Inject 6 | 7 | // MARK: - Models 8 | 9 | struct Transcript: Codable, Equatable, Identifiable { 10 | var id: UUID 11 | var timestamp: Date 12 | var text: String 13 | var audioPath: URL 14 | var duration: TimeInterval 15 | 16 | init(id: UUID = UUID(), timestamp: Date, text: String, audioPath: URL, duration: TimeInterval) { 17 | self.id = id 18 | self.timestamp = timestamp 19 | self.text = text 20 | self.audioPath = audioPath 21 | self.duration = duration 22 | } 23 | } 24 | 25 | struct TranscriptionHistory: Codable, Equatable { 26 | var history: [Transcript] = [] 27 | } 28 | 29 | extension SharedReaderKey 30 | where Self == FileStorageKey.Default 31 | { 32 | static var transcriptionHistory: Self { 33 | Self[ 34 | .fileStorage(URL.documentsDirectory.appending(component: "transcription_history.json")), 35 | default: .init() 36 | ] 37 | } 38 | } 39 | 40 | class AudioPlayerController: NSObject, AVAudioPlayerDelegate { 41 | private var player: AVAudioPlayer? 42 | var onPlaybackFinished: (() -> Void)? 43 | 44 | func play(url: URL) throws -> AVAudioPlayer { 45 | let player = try AVAudioPlayer(contentsOf: url) 46 | player.delegate = self 47 | player.play() 48 | self.player = player 49 | return player 50 | } 51 | 52 | func stop() { 53 | player?.stop() 54 | player = nil 55 | } 56 | 57 | // AVAudioPlayerDelegate method 58 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 59 | self.player = nil 60 | Task { @MainActor in 61 | onPlaybackFinished?() 62 | } 63 | } 64 | } 65 | 66 | // MARK: - History Feature 67 | 68 | @Reducer 69 | struct HistoryFeature { 70 | @ObservableState 71 | struct State: Equatable { 72 | @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory 73 | var playingTranscriptID: UUID? 74 | var audioPlayer: AVAudioPlayer? 75 | var audioPlayerController: AudioPlayerController? 76 | } 77 | 78 | enum Action { 79 | case playTranscript(UUID) 80 | case stopPlayback 81 | case copyToClipboard(String) 82 | case deleteTranscript(UUID) 83 | case deleteAllTranscripts 84 | case confirmDeleteAll 85 | case playbackFinished 86 | case navigateToSettings 87 | } 88 | 89 | @Dependency(\.pasteboard) var pasteboard 90 | 91 | var body: some ReducerOf { 92 | Reduce { state, action in 93 | switch action { 94 | case let .playTranscript(id): 95 | if state.playingTranscriptID == id { 96 | // Stop playback if tapping the same transcript 97 | state.audioPlayerController?.stop() 98 | state.audioPlayer = nil 99 | state.audioPlayerController = nil 100 | state.playingTranscriptID = nil 101 | return .none 102 | } 103 | 104 | // Stop any existing playback 105 | state.audioPlayerController?.stop() 106 | state.audioPlayer = nil 107 | state.audioPlayerController = nil 108 | 109 | // Find the transcript and play its audio 110 | guard let transcript = state.transcriptionHistory.history.first(where: { $0.id == id }) else { 111 | return .none 112 | } 113 | 114 | do { 115 | let controller = AudioPlayerController() 116 | let player = try controller.play(url: transcript.audioPath) 117 | 118 | state.audioPlayer = player 119 | state.audioPlayerController = controller 120 | state.playingTranscriptID = id 121 | 122 | return .run { send in 123 | // Using non-throwing continuation since we don't need to throw errors 124 | await withCheckedContinuation { continuation in 125 | controller.onPlaybackFinished = { 126 | continuation.resume() 127 | 128 | // Use Task to switch to MainActor for sending the action 129 | Task { @MainActor in 130 | send(.playbackFinished) 131 | } 132 | } 133 | } 134 | } 135 | } catch { 136 | print("Error playing audio: \(error)") 137 | return .none 138 | } 139 | 140 | case .stopPlayback, .playbackFinished: 141 | state.audioPlayerController?.stop() 142 | state.audioPlayer = nil 143 | state.audioPlayerController = nil 144 | state.playingTranscriptID = nil 145 | return .none 146 | 147 | case let .copyToClipboard(text): 148 | return .run { _ in 149 | NSPasteboard.general.clearContents() 150 | NSPasteboard.general.setString(text, forType: .string) 151 | } 152 | 153 | case let .deleteTranscript(id): 154 | guard let index = state.transcriptionHistory.history.firstIndex(where: { $0.id == id }) else { 155 | return .none 156 | } 157 | 158 | let transcript = state.transcriptionHistory.history[index] 159 | 160 | if state.playingTranscriptID == id { 161 | state.audioPlayerController?.stop() 162 | state.audioPlayer = nil 163 | state.audioPlayerController = nil 164 | state.playingTranscriptID = nil 165 | } 166 | 167 | _ = state.$transcriptionHistory.withLock { history in 168 | history.history.remove(at: index) 169 | } 170 | 171 | return .run { _ in 172 | try? FileManager.default.removeItem(at: transcript.audioPath) 173 | } 174 | 175 | case .deleteAllTranscripts: 176 | return .send(.confirmDeleteAll) 177 | 178 | case .confirmDeleteAll: 179 | let transcripts = state.transcriptionHistory.history 180 | 181 | state.audioPlayerController?.stop() 182 | state.audioPlayer = nil 183 | state.audioPlayerController = nil 184 | state.playingTranscriptID = nil 185 | 186 | state.$transcriptionHistory.withLock { history in 187 | history.history.removeAll() 188 | } 189 | 190 | return .run { _ in 191 | for transcript in transcripts { 192 | try? FileManager.default.removeItem(at: transcript.audioPath) 193 | } 194 | } 195 | 196 | case .navigateToSettings: 197 | // This will be handled by the parent reducer 198 | return .none 199 | } 200 | } 201 | } 202 | } 203 | 204 | struct TranscriptView: View { 205 | let transcript: Transcript 206 | let isPlaying: Bool 207 | let onPlay: () -> Void 208 | let onCopy: () -> Void 209 | let onDelete: () -> Void 210 | 211 | var body: some View { 212 | VStack(alignment: .leading, spacing: 0) { 213 | Text(transcript.text) 214 | .font(.body) 215 | .lineLimit(nil) 216 | .fixedSize(horizontal: false, vertical: true) 217 | .padding(.trailing, 40) // Space for buttons 218 | .padding(12) 219 | 220 | Divider() 221 | 222 | HStack { 223 | HStack(spacing: 6) { 224 | Image(systemName: "clock") 225 | Text(transcript.timestamp.formatted(date: .numeric, time: .shortened)) 226 | Text("•") 227 | Text(String(format: "%.1fs", transcript.duration)) 228 | } 229 | .font(.subheadline) 230 | .foregroundStyle(.secondary) 231 | 232 | Spacer() 233 | 234 | HStack(spacing: 10) { 235 | Button { 236 | onCopy() 237 | showCopyAnimation() 238 | } label: { 239 | HStack(spacing: 4) { 240 | Image(systemName: showCopied ? "checkmark" : "doc.on.doc.fill") 241 | if showCopied { 242 | Text("Copied").font(.caption) 243 | } 244 | } 245 | } 246 | .buttonStyle(.plain) 247 | .foregroundStyle(showCopied ? .green : .secondary) 248 | .help("Copy to clipboard") 249 | 250 | Button(action: onPlay) { 251 | Image(systemName: isPlaying ? "stop.fill" : "play.fill") 252 | } 253 | .buttonStyle(.plain) 254 | .foregroundStyle(isPlaying ? .blue : .secondary) 255 | .help(isPlaying ? "Stop playback" : "Play audio") 256 | 257 | Button(action: onDelete) { 258 | Image(systemName: "trash.fill") 259 | } 260 | .buttonStyle(.plain) 261 | .foregroundStyle(.secondary) 262 | .help("Delete transcript") 263 | } 264 | .font(.subheadline) 265 | } 266 | .frame(height: 20) 267 | .padding(.horizontal, 12) 268 | .padding(.vertical, 6) 269 | } 270 | .background( 271 | RoundedRectangle(cornerRadius: 8) 272 | .fill(Color(.windowBackgroundColor).opacity(0.5)) 273 | .overlay( 274 | RoundedRectangle(cornerRadius: 8) 275 | .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) 276 | ) 277 | ) 278 | .onDisappear { 279 | // Clean up any running task when view disappears 280 | copyTask?.cancel() 281 | } 282 | } 283 | 284 | @State private var showCopied = false 285 | @State private var copyTask: Task? 286 | 287 | private func showCopyAnimation() { 288 | copyTask?.cancel() 289 | 290 | copyTask = Task { 291 | withAnimation { 292 | showCopied = true 293 | } 294 | 295 | try await Task.sleep(for: .seconds(1.5)) 296 | 297 | withAnimation { 298 | showCopied = false 299 | } 300 | } 301 | } 302 | } 303 | 304 | #Preview { 305 | TranscriptView( 306 | transcript: Transcript(timestamp: Date(), text: "Hello, world!", audioPath: URL(fileURLWithPath: "/Users/langton/Downloads/test.m4a"), duration: 1.0), 307 | isPlaying: false, 308 | onPlay: {}, 309 | onCopy: {}, 310 | onDelete: {} 311 | ) 312 | } 313 | 314 | struct HistoryView: View { 315 | @ObserveInjection var inject 316 | let store: StoreOf 317 | @State private var showingDeleteConfirmation = false 318 | @Shared(.hexSettings) var hexSettings: HexSettings 319 | 320 | var body: some View { 321 | Group { 322 | if !hexSettings.saveTranscriptionHistory { 323 | ContentUnavailableView { 324 | Label("History Disabled", systemImage: "clock.arrow.circlepath") 325 | } description: { 326 | Text("Transcription history is currently disabled.") 327 | } actions: { 328 | Button("Enable in Settings") { 329 | store.send(.navigateToSettings) 330 | } 331 | } 332 | } else if store.transcriptionHistory.history.isEmpty { 333 | ContentUnavailableView { 334 | Label("No Transcriptions", systemImage: "text.bubble") 335 | } description: { 336 | Text("Your transcription history will appear here.") 337 | } 338 | } else { 339 | ScrollView { 340 | LazyVStack(spacing: 12) { 341 | ForEach(store.transcriptionHistory.history) { transcript in 342 | TranscriptView( 343 | transcript: transcript, 344 | isPlaying: store.playingTranscriptID == transcript.id, 345 | onPlay: { store.send(.playTranscript(transcript.id)) }, 346 | onCopy: { store.send(.copyToClipboard(transcript.text)) }, 347 | onDelete: { store.send(.deleteTranscript(transcript.id)) } 348 | ) 349 | } 350 | } 351 | .padding() 352 | } 353 | .toolbar { 354 | Button(role: .destructive, action: { showingDeleteConfirmation = true }) { 355 | Label("Delete All", systemImage: "trash") 356 | } 357 | } 358 | .alert("Delete All Transcripts", isPresented: $showingDeleteConfirmation) { 359 | Button("Delete All", role: .destructive) { 360 | store.send(.confirmDeleteAll) 361 | } 362 | Button("Cancel", role: .cancel) {} 363 | } message: { 364 | Text("Are you sure you want to delete all transcripts? This action cannot be undone.") 365 | } 366 | } 367 | }.enableInjection() 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /Hex/Features/Settings/AboutView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Inject 3 | import SwiftUI 4 | import Sparkle 5 | 6 | struct AboutView: View { 7 | @ObserveInjection var inject 8 | @Bindable var store: StoreOf 9 | @State var viewModel = CheckForUpdatesViewModel.shared 10 | @State private var showingChangelog = false 11 | 12 | var body: some View { 13 | Form { 14 | Section { 15 | HStack { 16 | Label("Version", systemImage: "info.circle") 17 | Spacer() 18 | Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown") 19 | Button("Check for Updates") { 20 | viewModel.checkForUpdates() 21 | } 22 | .buttonStyle(.bordered) 23 | } 24 | HStack { 25 | Label("Changelog", systemImage: "doc.text") 26 | Spacer() 27 | Button("Show Changelog") { 28 | showingChangelog.toggle() 29 | } 30 | .buttonStyle(.bordered) 31 | .sheet(isPresented: $showingChangelog, onDismiss: { 32 | showingChangelog = false 33 | }) { 34 | ChangelogView() 35 | } 36 | } 37 | HStack { 38 | Label("Hex is open source", systemImage: "apple.terminal.on.rectangle") 39 | Spacer() 40 | Link("Visit our GitHub", destination: URL(string: "https://github.com/kitlangton/Hex/")!) 41 | } 42 | 43 | HStack { 44 | Label("Support the developer", systemImage: "heart") 45 | Spacer() 46 | Link("Become a Sponsor", destination: URL(string: "https://github.com/sponsors/kitlangton")!) 47 | } 48 | } 49 | } 50 | .formStyle(.grouped) 51 | .enableInjection() 52 | } 53 | } -------------------------------------------------------------------------------- /Hex/Features/Settings/ChangelogView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Inject 3 | import MarkdownUI 4 | 5 | struct ChangelogView: View { 6 | @ObserveInjection var inject 7 | @Environment(\.dismiss) var dismiss 8 | 9 | var body: some View { 10 | ScrollView { 11 | VStack(alignment: .leading, spacing: 10) { 12 | Text("Changelog") 13 | .font(.title) 14 | .padding(.bottom, 10) 15 | 16 | if let changelogPath = Bundle.main.path(forResource: "changelog", ofType: "md"), 17 | let changelogContent = try? String( 18 | contentsOfFile: changelogPath, encoding: .utf8) 19 | { 20 | Markdown(changelogContent) 21 | } else { 22 | Text("Changelog could not be loaded.") 23 | .foregroundColor(.red) 24 | } 25 | 26 | Spacer() 27 | 28 | Button("Close") { 29 | dismiss() 30 | } 31 | .buttonStyle(.borderedProminent) 32 | .padding(.top, 20) 33 | } 34 | .padding() 35 | } 36 | .enableInjection() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Hex/Features/Settings/HotKeyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotKeyView.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/30/25. 6 | // 7 | 8 | import Inject 9 | import Sauce 10 | import SwiftUI 11 | 12 | // This view shows the actual "keys" in a more modern, subtle style. 13 | struct HotKeyView: View { 14 | @ObserveInjection var inject 15 | var modifiers: Modifiers 16 | var key: Key? 17 | var isActive: Bool 18 | 19 | var body: some View { 20 | HStack(spacing: 6) { 21 | if modifiers.isHyperkey { 22 | // Show Black Four Pointed Star for hyperkey 23 | KeyView(text: "✦") 24 | .transition(.blurReplace) 25 | } else { 26 | ForEach(modifiers.sorted) { modifier in 27 | KeyView(text: modifier.stringValue) 28 | .transition(.blurReplace) 29 | } 30 | } 31 | 32 | if let key { 33 | KeyView(text: key.toString) 34 | } 35 | 36 | if modifiers.isEmpty && key == nil { 37 | Text("") 38 | .font(.system(size: 12, weight: .regular, design: .monospaced)) 39 | .frame(width: 48, height: 48) 40 | } 41 | } 42 | .padding(8) 43 | .frame(maxWidth: .infinity) 44 | .background { 45 | if isActive && key == nil && modifiers.isEmpty { 46 | Text("Enter a key combination") 47 | .foregroundColor(.secondary) 48 | .transition(.blurReplace) 49 | } 50 | } 51 | .background( 52 | RoundedRectangle(cornerRadius: 6) 53 | .fill(Color.blue.opacity(isActive ? 0.1 : 0)) 54 | .stroke(Color.blue.opacity(isActive ? 0.2 : 0), lineWidth: 1) 55 | ) 56 | 57 | .animation(.bouncy(duration: 0.3), value: key) 58 | .animation(.bouncy(duration: 0.3), value: modifiers) 59 | .animation(.bouncy(duration: 0.3), value: isActive) 60 | .enableInjection() 61 | } 62 | } 63 | 64 | struct KeyView: View { 65 | @ObserveInjection var inject 66 | var text: String 67 | 68 | var body: some View { 69 | Text(text) 70 | .font(.title.weight(.bold)) 71 | .foregroundColor(.white) 72 | .frame(width: 48, height: 48) 73 | .background( 74 | RoundedRectangle(cornerRadius: 8) 75 | .fill( 76 | .black.mix(with: .white, by: 0.2) 77 | .shadow(.inner(color: .white.opacity(0.3), radius: 1, y: 1)) 78 | .shadow(.inner(color: .white.opacity(0.1), radius: 5, y: 8)) 79 | .shadow(.inner(color: .black.opacity(0.3), radius: 1, y: -3)) 80 | ) 81 | ) 82 | .shadow(radius: 4, y: 2) 83 | .enableInjection() 84 | } 85 | } 86 | 87 | #Preview { 88 | HotKeyView( 89 | modifiers: .init(modifiers: [.command, .shift]), 90 | key: .a, 91 | isActive: true 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /Hex/Features/Settings/ModelDownloadFeature.swift: -------------------------------------------------------------------------------- 1 | // MARK: – ModelDownloadFeature.swift 2 | 3 | // A full‐featured TCA reducer + SwiftUI view for managing on‑device ML models. 4 | // The file is single‑purpose but split into logical sections for clarity. 5 | // Dependencies: ComposableArchitecture, IdentifiedCollections, Dependencies, SwiftUI 6 | 7 | import ComposableArchitecture 8 | import Dependencies 9 | import IdentifiedCollections 10 | import SwiftUI 11 | 12 | // ────────────────────────────────────────────────────────────────────────── 13 | 14 | // MARK: – Data Models 15 | 16 | // ────────────────────────────────────────────────────────────────────────── 17 | 18 | public struct ModelInfo: Equatable, Identifiable { 19 | public let name: String 20 | public var isDownloaded: Bool 21 | 22 | public var id: String { name } 23 | public init(name: String, isDownloaded: Bool) { 24 | self.name = name 25 | self.isDownloaded = isDownloaded 26 | } 27 | } 28 | 29 | public struct CuratedModelInfo: Equatable, Identifiable, Codable { 30 | public let displayName: String 31 | public let internalName: String 32 | public let size: String 33 | public let accuracyStars: Int 34 | public let speedStars: Int 35 | public let storageSize: String 36 | public var isDownloaded: Bool 37 | public var id: String { internalName } 38 | 39 | public init( 40 | displayName: String, 41 | internalName: String, 42 | size: String, 43 | accuracyStars: Int, 44 | speedStars: Int, 45 | storageSize: String, 46 | isDownloaded: Bool 47 | ) { 48 | self.displayName = displayName 49 | self.internalName = internalName 50 | self.size = size 51 | self.accuracyStars = accuracyStars 52 | self.speedStars = speedStars 53 | self.storageSize = storageSize 54 | self.isDownloaded = isDownloaded 55 | } 56 | 57 | // Codable (isDownloaded is set at runtime) 58 | private enum CodingKeys: String, CodingKey { case displayName, internalName, size, accuracyStars, speedStars, storageSize } 59 | public init(from decoder: Decoder) throws { 60 | let c = try decoder.container(keyedBy: CodingKeys.self) 61 | displayName = try c.decode(String.self, forKey: .displayName) 62 | internalName = try c.decode(String.self, forKey: .internalName) 63 | size = try c.decode(String.self, forKey: .size) 64 | accuracyStars = try c.decode(Int.self, forKey: .accuracyStars) 65 | speedStars = try c.decode(Int.self, forKey: .speedStars) 66 | storageSize = try c.decode(String.self, forKey: .storageSize) 67 | isDownloaded = false 68 | } 69 | } 70 | 71 | // Convenience helper for loading the bundled models.json once. 72 | private enum CuratedModelLoader { 73 | static func load() -> [CuratedModelInfo] { 74 | guard let url = Bundle.main.url(forResource: "models", withExtension: "json") ?? 75 | Bundle.main.url(forResource: "models", withExtension: "json", subdirectory: "Data") 76 | else { 77 | assertionFailure("models.json not found in bundle") 78 | return [] 79 | } 80 | do { return try JSONDecoder().decode([CuratedModelInfo].self, from: Data(contentsOf: url)) } 81 | catch { assertionFailure("Failed to decode models.json – \(error)"); return [] } 82 | } 83 | } 84 | 85 | // ────────────────────────────────────────────────────────────────────────── 86 | 87 | // MARK: – Domain 88 | 89 | // ────────────────────────────────────────────────────────────────────────── 90 | 91 | @Reducer 92 | public struct ModelDownloadFeature { 93 | @ObservableState 94 | public struct State: Equatable { 95 | // Shared user settings 96 | @Shared(.hexSettings) var hexSettings: HexSettings 97 | 98 | // Remote data 99 | public var availableModels: IdentifiedArrayOf = [] 100 | public var curatedModels: IdentifiedArrayOf = [] 101 | public var recommendedModel: String = "" 102 | 103 | // UI state 104 | public var showAllModels = false 105 | public var isDownloading = false 106 | public var downloadProgress: Double = 0 107 | public var downloadError: String? 108 | public var downloadingModelName: String? 109 | 110 | // Track which model generated a progress update to handle switching models 111 | public var activeDownloadID: UUID? 112 | 113 | // Convenience computed vars 114 | var selectedModel: String { hexSettings.selectedModel } 115 | var selectedModelIsDownloaded: Bool { 116 | availableModels[id: selectedModel]?.isDownloaded ?? false 117 | } 118 | 119 | var anyModelDownloaded: Bool { 120 | availableModels.contains(where: { $0.isDownloaded }) 121 | } 122 | } 123 | 124 | // MARK: Actions 125 | 126 | public enum Action: BindableAction { 127 | case binding(BindingAction) 128 | // Requests 129 | case fetchModels 130 | case selectModel(String) 131 | case toggleModelDisplay 132 | case downloadSelectedModel 133 | // Effects 134 | case modelsLoaded(recommended: String, available: [ModelInfo]) 135 | case downloadProgress(Double) 136 | case downloadCompleted(Result) 137 | 138 | case deleteSelectedModel 139 | case openModelLocation 140 | } 141 | 142 | // MARK: Dependencies 143 | 144 | @Dependency(\.transcription) var transcription 145 | @Dependency(\.continuousClock) var clock 146 | 147 | public init() {} 148 | 149 | // MARK: Reducer 150 | 151 | public var body: some ReducerOf { 152 | BindingReducer() 153 | Reduce(reduce) 154 | } 155 | 156 | 157 | private func reduce(state: inout State, action: Action) -> Effect { 158 | switch action { 159 | // MARK: – UI bindings 160 | 161 | case .binding: 162 | return .none 163 | 164 | case .toggleModelDisplay: 165 | state.showAllModels.toggle() 166 | return .none 167 | 168 | case let .selectModel(model): 169 | state.$hexSettings.withLock { $0.selectedModel = model } 170 | return .none 171 | 172 | // MARK: – Fetch Models 173 | 174 | case .fetchModels: 175 | return .run { send in 176 | do { 177 | let recommended = try await transcription.getRecommendedModels().default 178 | let names = try await transcription.getAvailableModels() 179 | let infos = try await withThrowingTaskGroup(of: ModelInfo.self) { group -> [ModelInfo] in 180 | for name in names { 181 | group.addTask { 182 | ModelInfo( 183 | name: name, 184 | isDownloaded: await transcription.isModelDownloaded(name) 185 | ) 186 | } 187 | } 188 | return try await group.reduce(into: []) { $0.append($1) } 189 | } 190 | await send(.modelsLoaded(recommended: recommended, available: infos)) 191 | } catch { 192 | await send(.modelsLoaded(recommended: "", available: [])) 193 | } 194 | } 195 | 196 | case let .modelsLoaded(recommended, available): 197 | state.recommendedModel = recommended 198 | state.availableModels = IdentifiedArrayOf(uniqueElements: available) 199 | // Merge curated + download status 200 | var curated = CuratedModelLoader.load() 201 | for idx in curated.indices { 202 | curated[idx].isDownloaded = available.first(where: { $0.name == curated[idx].internalName })?.isDownloaded ?? false 203 | } 204 | state.curatedModels = IdentifiedArrayOf(uniqueElements: curated) 205 | return .none 206 | 207 | // MARK: – Download 208 | 209 | case .downloadSelectedModel: 210 | guard !state.selectedModel.isEmpty else { return .none } 211 | state.downloadError = nil 212 | state.isDownloading = true 213 | state.downloadingModelName = state.selectedModel 214 | return .run { [state] send in 215 | do { 216 | // Assume downloadModel returns AsyncThrowingStream 217 | try await transcription.downloadModel(state.selectedModel) { progress in 218 | Task { await send(.downloadProgress(progress.fractionCompleted)) } 219 | } 220 | await send(.downloadCompleted(.success(state.selectedModel))) 221 | } catch { 222 | await send(.downloadCompleted(.failure(error))) 223 | } 224 | } 225 | 226 | case let .downloadProgress(progress): 227 | state.downloadProgress = progress 228 | return .none 229 | 230 | case let .downloadCompleted(result): 231 | state.isDownloading = false 232 | state.downloadingModelName = nil 233 | switch result { 234 | case let .success(name): 235 | state.availableModels[id: name]?.isDownloaded = true 236 | if let idx = state.curatedModels.firstIndex(where: { $0.internalName == name }) { 237 | state.curatedModels[idx].isDownloaded = true 238 | } 239 | case let .failure(err): 240 | state.downloadError = err.localizedDescription 241 | } 242 | return .none 243 | 244 | case .deleteSelectedModel: 245 | guard !state.selectedModel.isEmpty else { return .none } 246 | return .run { [state] send in 247 | do { 248 | try await transcription.deleteModel(state.selectedModel) 249 | await send(.fetchModels) 250 | } catch { 251 | await send(.downloadCompleted(.failure(error))) 252 | } 253 | } 254 | 255 | case .openModelLocation: 256 | return openModelLocationEffect() 257 | } 258 | } 259 | 260 | // MARK: Helpers 261 | 262 | private func openModelLocationEffect() -> Effect { 263 | .run { _ in 264 | let fm = FileManager.default 265 | let base = try fm.url( 266 | for: .applicationSupportDirectory, 267 | in: .userDomainMask, 268 | appropriateFor: nil, 269 | create: true 270 | ) 271 | .appendingPathComponent("com.kitlangton.Hex/models", isDirectory: true) 272 | 273 | if !fm.fileExists(atPath: base.path) { 274 | try fm.createDirectory(at: base, withIntermediateDirectories: true) 275 | } 276 | NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: base.path) 277 | } 278 | } 279 | } 280 | 281 | // ────────────────────────────────────────────────────────────────────────── 282 | 283 | // MARK: – SwiftUI Views 284 | 285 | // ────────────────────────────────────────────────────────────────────────── 286 | 287 | private struct StarRatingView: View { 288 | let filled: Int 289 | let max: Int 290 | 291 | init(_ filled: Int, max: Int = 5) { 292 | self.filled = filled 293 | self.max = max 294 | } 295 | 296 | var body: some View { 297 | HStack(spacing: 3) { 298 | ForEach(0 ..< max, id: \.self) { i in 299 | Image(systemName: i < filled ? "circle.fill" : "circle") 300 | .font(.system(size: 7)) 301 | .foregroundColor(i < filled ? .blue : .gray.opacity(0.5)) 302 | } 303 | } 304 | } 305 | } 306 | 307 | public struct ModelDownloadView: View { 308 | @Bindable var store: StoreOf 309 | 310 | public init(store: StoreOf) { 311 | self.store = store 312 | } 313 | 314 | public var body: some View { 315 | VStack(alignment: .leading, spacing: 12) { 316 | HeaderView(store: store) 317 | Group { 318 | if store.showAllModels { 319 | AllModelsPicker(store: store) 320 | } else { 321 | CuratedList(store: store) 322 | } 323 | } 324 | if let err = store.downloadError { 325 | Text("Download Error: \(err)") 326 | .foregroundColor(.red) 327 | .font(.caption) 328 | } 329 | FooterView(store: store) 330 | } 331 | .task { 332 | if store.availableModels.isEmpty { 333 | store.send(.fetchModels) 334 | } 335 | } 336 | .onAppear { 337 | store.send(.fetchModels) 338 | } 339 | } 340 | } 341 | 342 | // MARK: – Subviews 343 | 344 | private struct HeaderView: View { 345 | @Bindable var store: StoreOf 346 | 347 | var body: some View { 348 | HStack { 349 | Text(store.showAllModels ? "Showing all models" : "Showing recommended models") 350 | .font(.caption) 351 | .foregroundColor(.secondary) 352 | Spacer() 353 | Button( 354 | store.showAllModels ? "Show Recommended" : "Show All Models" 355 | ) { 356 | store.send(.toggleModelDisplay) 357 | } 358 | .font(.caption) 359 | } 360 | } 361 | } 362 | 363 | private struct AllModelsPicker: View { 364 | @Bindable var store: StoreOf 365 | 366 | var body: some View { 367 | Picker( 368 | "Selected Model", 369 | selection: Binding( 370 | get: { store.hexSettings.selectedModel }, 371 | set: { store.send(.selectModel($0)) } 372 | ) 373 | ) { 374 | ForEach(store.availableModels) { info in 375 | HStack { 376 | Text( 377 | info.name == store.recommendedModel 378 | ? "\(info.name) (Recommended)" 379 | : info.name 380 | ) 381 | Spacer() 382 | if info.isDownloaded { 383 | Image(systemName: "checkmark.circle.fill") 384 | .foregroundColor(.green) 385 | } 386 | } 387 | .tag(info.name) 388 | } 389 | } 390 | .pickerStyle(.menu) 391 | } 392 | } 393 | 394 | private struct CuratedList: View { 395 | @Bindable var store: StoreOf 396 | 397 | var body: some View { 398 | VStack(alignment: .leading, spacing: 8) { 399 | // Header 400 | HStack(alignment: .bottom) { 401 | Text("Model") 402 | .frame(minWidth: 80, alignment: .leading) 403 | .font(.caption.bold()) 404 | Spacer() 405 | Text("Accuracy") 406 | .frame(minWidth: 80, alignment: .leading) 407 | .font(.caption.bold()) 408 | Spacer() 409 | Text("Speed") 410 | .frame(minWidth: 80, alignment: .leading) 411 | .font(.caption.bold()) 412 | Spacer() 413 | Text("Size") 414 | .frame(minWidth: 70, alignment: .leading) 415 | .font(.caption.bold()) 416 | } 417 | .padding(.horizontal, 8) 418 | 419 | ForEach(store.curatedModels) { model in 420 | CuratedRow(store: store, model: model) 421 | } 422 | } 423 | } 424 | } 425 | 426 | private struct CuratedRow: View { 427 | @Bindable var store: StoreOf 428 | let model: CuratedModelInfo 429 | 430 | var isSelected: Bool { 431 | model.internalName == store.hexSettings.selectedModel 432 | } 433 | 434 | var body: some View { 435 | Button( 436 | action: { store.send(.selectModel(model.internalName)) } 437 | ) { 438 | HStack { 439 | HStack { 440 | Text(model.displayName) 441 | .font(.headline) 442 | if model.isDownloaded { 443 | Image(systemName: "checkmark.circle.fill") 444 | .foregroundColor(.green) 445 | } 446 | if isSelected { 447 | Image(systemName: "checkmark") 448 | .foregroundColor(.blue) 449 | } 450 | } 451 | .frame(minWidth: 80, alignment: .leading) 452 | Spacer() 453 | StarRatingView(model.accuracyStars) 454 | .frame(minWidth: 80, alignment: .leading) 455 | Spacer() 456 | StarRatingView(model.speedStars) 457 | .frame(minWidth: 80, alignment: .leading) 458 | Spacer() 459 | Text(model.storageSize) 460 | .foregroundColor(.secondary) 461 | .frame(minWidth: 70, alignment: .leading) 462 | } 463 | .padding(8) 464 | .background( 465 | RoundedRectangle(cornerRadius: 8) 466 | .fill(isSelected ? Color.blue.opacity(0.1) : Color.clear) 467 | ) 468 | .overlay( 469 | RoundedRectangle(cornerRadius: 8) 470 | .stroke( 471 | isSelected 472 | ? Color.blue.opacity(0.3) 473 | : Color.gray.opacity(0.2) 474 | ) 475 | ) 476 | .contentShape(.rect) 477 | } 478 | .buttonStyle(.plain) 479 | } 480 | } 481 | 482 | private struct FooterView: View { 483 | @Bindable var store: StoreOf 484 | 485 | var body: some View { 486 | if store.isDownloading, store.downloadingModelName == store.hexSettings.selectedModel { 487 | VStack(alignment: .leading) { 488 | Text("Downloading model...") 489 | .font(.caption) 490 | ProgressView(value: store.downloadProgress) 491 | .tint(.blue) 492 | } 493 | } else { 494 | HStack { 495 | if let selected = store.curatedModels.first(where: { $0.internalName == store.hexSettings.selectedModel }) { 496 | Text("Selected: \(selected.displayName)") 497 | .font(.caption) 498 | } 499 | Spacer() 500 | if store.anyModelDownloaded { 501 | Button("Show Models Folder") { 502 | store.send(.openModelLocation) 503 | } 504 | .font(.caption) 505 | .buttonStyle(.plain) 506 | .foregroundStyle(.secondary) 507 | } 508 | if store.selectedModelIsDownloaded { 509 | Button("Delete", role: .destructive) { 510 | store.send(.deleteSelectedModel) 511 | } 512 | .font(.caption) 513 | .buttonStyle(.plain) 514 | .foregroundStyle(.secondary) 515 | } else if !store.selectedModel.isEmpty { 516 | Button("Download") { 517 | store.send(.downloadSelectedModel) 518 | } 519 | .font(.caption) 520 | .buttonStyle(.plain) 521 | .foregroundStyle(.secondary) 522 | } 523 | } 524 | .enableInjection() 525 | } 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /Hex/Features/Settings/SettingsFeature.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import ComposableArchitecture 3 | import Dependencies 4 | import IdentifiedCollections 5 | import Sauce 6 | import ServiceManagement 7 | import SwiftUI 8 | 9 | extension SharedReaderKey 10 | where Self == InMemoryKey.Default 11 | { 12 | static var isSettingHotKey: Self { 13 | Self[.inMemory("isSettingHotKey"), default: false] 14 | } 15 | } 16 | 17 | // MARK: - Settings Feature 18 | 19 | @Reducer 20 | struct SettingsFeature { 21 | @ObservableState 22 | struct State { 23 | @Shared(.hexSettings) var hexSettings: HexSettings 24 | @Shared(.isSettingHotKey) var isSettingHotKey: Bool = false 25 | @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory 26 | 27 | var languages: IdentifiedArrayOf = [] 28 | var currentModifiers: Modifiers = .init(modifiers: []) 29 | 30 | // Available microphones 31 | var availableInputDevices: [AudioInputDevice] = [] 32 | 33 | // Permissions 34 | var microphonePermission: PermissionStatus = .notDetermined 35 | var accessibilityPermission: PermissionStatus = .notDetermined 36 | 37 | // Model Management 38 | var modelDownload = ModelDownloadFeature.State() 39 | } 40 | 41 | enum Action: BindableAction { 42 | case binding(BindingAction) 43 | 44 | // Existing 45 | case task 46 | case startSettingHotKey 47 | case keyEvent(KeyEvent) 48 | case toggleOpenOnLogin(Bool) 49 | case togglePreventSystemSleep(Bool) 50 | case togglePauseMediaOnRecord(Bool) 51 | case checkPermissions 52 | case setMicrophonePermission(PermissionStatus) 53 | case setAccessibilityPermission(PermissionStatus) 54 | case requestMicrophonePermission 55 | case requestAccessibilityPermission 56 | case accessibilityStatusDidChange 57 | 58 | // Microphone selection 59 | case loadAvailableInputDevices 60 | case availableInputDevicesLoaded([AudioInputDevice]) 61 | 62 | // Model Management 63 | case modelDownload(ModelDownloadFeature.Action) 64 | 65 | // History Management 66 | case toggleSaveTranscriptionHistory(Bool) 67 | } 68 | 69 | @Dependency(\.keyEventMonitor) var keyEventMonitor 70 | @Dependency(\.continuousClock) var clock 71 | @Dependency(\.transcription) var transcription 72 | @Dependency(\.recording) var recording 73 | 74 | var body: some ReducerOf { 75 | BindingReducer() 76 | 77 | Scope(state: \.modelDownload, action: \.modelDownload) { 78 | ModelDownloadFeature() 79 | } 80 | 81 | Reduce { state, action in 82 | switch action { 83 | case .binding: 84 | return .run { _ in 85 | await MainActor.run { 86 | NotificationCenter.default.post(name: NSNotification.Name("UpdateAppMode"), object: nil) 87 | } 88 | } 89 | 90 | case .task: 91 | if let url = Bundle.main.url(forResource: "languages", withExtension: "json"), 92 | let data = try? Data(contentsOf: url), 93 | let languages = try? JSONDecoder().decode([Language].self, from: data) 94 | { 95 | state.languages = IdentifiedArray(uniqueElements: languages) 96 | } else { 97 | print("Failed to load languages") 98 | } 99 | 100 | // Listen for key events and load microphones (existing + new) 101 | return .run { send in 102 | await send(.checkPermissions) 103 | await send(.modelDownload(.fetchModels)) 104 | await send(.loadAvailableInputDevices) 105 | 106 | // Set up periodic refresh of available devices (every 120 seconds) 107 | // Using a longer interval to reduce resource usage 108 | let deviceRefreshTask = Task { @MainActor in 109 | for await _ in clock.timer(interval: .seconds(120)) { 110 | // Only refresh when the app is active to save resources 111 | if await NSApplication.shared.isActive { 112 | await send(.loadAvailableInputDevices) 113 | } 114 | } 115 | } 116 | 117 | // Listen for device connection/disconnection notifications 118 | // Using a simpler debounced approach with a single task 119 | var deviceUpdateTask: Task? 120 | 121 | // Helper function to debounce device updates 122 | func debounceDeviceUpdate() { 123 | deviceUpdateTask?.cancel() 124 | deviceUpdateTask = Task { 125 | try? await Task.sleep(nanoseconds: 500_000_000) // 500ms 126 | if !Task.isCancelled { 127 | await send(.loadAvailableInputDevices) 128 | } 129 | } 130 | } 131 | 132 | let deviceConnectionObserver = NotificationCenter.default.addObserver( 133 | forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"), 134 | object: nil, 135 | queue: .main 136 | ) { _ in 137 | debounceDeviceUpdate() 138 | } 139 | 140 | let deviceDisconnectionObserver = NotificationCenter.default.addObserver( 141 | forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"), 142 | object: nil, 143 | queue: .main 144 | ) { _ in 145 | debounceDeviceUpdate() 146 | } 147 | 148 | // Be sure to clean up resources when the task is finished 149 | defer { 150 | deviceUpdateTask?.cancel() 151 | NotificationCenter.default.removeObserver(deviceConnectionObserver) 152 | NotificationCenter.default.removeObserver(deviceDisconnectionObserver) 153 | } 154 | 155 | for try await keyEvent in await keyEventMonitor.listenForKeyPress() { 156 | await send(.keyEvent(keyEvent)) 157 | } 158 | 159 | deviceRefreshTask.cancel() 160 | } 161 | 162 | case .startSettingHotKey: 163 | state.$isSettingHotKey.withLock { $0 = true } 164 | return .none 165 | 166 | case let .keyEvent(keyEvent): 167 | guard state.isSettingHotKey else { return .none } 168 | 169 | if keyEvent.key == .escape { 170 | state.$isSettingHotKey.withLock { $0 = false } 171 | state.currentModifiers = [] 172 | return .none 173 | } 174 | 175 | state.currentModifiers = keyEvent.modifiers.union(state.currentModifiers) 176 | let currentModifiers = state.currentModifiers 177 | if let key = keyEvent.key { 178 | state.$hexSettings.withLock { 179 | $0.hotkey.key = key 180 | $0.hotkey.modifiers = currentModifiers 181 | } 182 | state.$isSettingHotKey.withLock { $0 = false } 183 | state.currentModifiers = [] 184 | } else if keyEvent.modifiers.isEmpty { 185 | state.$hexSettings.withLock { 186 | $0.hotkey.key = nil 187 | $0.hotkey.modifiers = currentModifiers 188 | } 189 | state.$isSettingHotKey.withLock { $0 = false } 190 | state.currentModifiers = [] 191 | } 192 | return .none 193 | 194 | case let .toggleOpenOnLogin(enabled): 195 | state.$hexSettings.withLock { $0.openOnLogin = enabled } 196 | return .run { _ in 197 | if enabled { 198 | try? SMAppService.mainApp.register() 199 | } else { 200 | try? SMAppService.mainApp.unregister() 201 | } 202 | } 203 | 204 | case let .togglePreventSystemSleep(enabled): 205 | state.$hexSettings.withLock { $0.preventSystemSleep = enabled } 206 | return .none 207 | 208 | case let .togglePauseMediaOnRecord(enabled): 209 | state.$hexSettings.withLock { $0.pauseMediaOnRecord = enabled } 210 | return .none 211 | 212 | // Permissions 213 | case .checkPermissions: 214 | // Check microphone 215 | return .merge( 216 | .run { send in 217 | let currentStatus = await checkMicrophonePermission() 218 | await send(.setMicrophonePermission(currentStatus)) 219 | }, 220 | .run { send in 221 | let currentStatus = checkAccessibilityPermission() 222 | await send(.setAccessibilityPermission(currentStatus)) 223 | } 224 | ) 225 | 226 | case let .setMicrophonePermission(status): 227 | state.microphonePermission = status 228 | return .none 229 | 230 | case let .setAccessibilityPermission(status): 231 | state.accessibilityPermission = status 232 | if status == .granted { 233 | return .run { _ in 234 | await keyEventMonitor.startMonitoring() 235 | } 236 | } else { 237 | return .none 238 | } 239 | 240 | case .requestMicrophonePermission: 241 | return .run { send in 242 | let granted = await requestMicrophonePermissionImpl() 243 | let status: PermissionStatus = granted ? .granted : .denied 244 | await send(.setMicrophonePermission(status)) 245 | } 246 | 247 | case .requestAccessibilityPermission: 248 | return .run { send in 249 | // First, prompt the user with the system dialog 250 | let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary 251 | _ = AXIsProcessTrustedWithOptions(options) 252 | 253 | // Open System Settings 254 | NSWorkspace.shared.open( 255 | URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! 256 | ) 257 | 258 | // Poll for changes every second until granted 259 | for await _ in self.clock.timer(interval: .seconds(0.5)) { 260 | let newStatus = checkAccessibilityPermission() 261 | await send(.setAccessibilityPermission(newStatus)) 262 | 263 | // If permission is granted, we can stop polling 264 | if newStatus == .granted { 265 | break 266 | } 267 | } 268 | } 269 | 270 | case .accessibilityStatusDidChange: 271 | let newStatus = checkAccessibilityPermission() 272 | state.accessibilityPermission = newStatus 273 | return .none 274 | 275 | // Model Management 276 | case let .modelDownload(.selectModel(newModel)): 277 | // Also store it in hexSettings: 278 | state.$hexSettings.withLock { 279 | $0.selectedModel = newModel 280 | } 281 | // Then continue with the child's normal logic: 282 | return .none 283 | 284 | case .modelDownload: 285 | return .none 286 | 287 | // Microphone device selection 288 | case .loadAvailableInputDevices: 289 | return .run { send in 290 | let devices = await recording.getAvailableInputDevices() 291 | await send(.availableInputDevicesLoaded(devices)) 292 | } 293 | 294 | case let .availableInputDevicesLoaded(devices): 295 | state.availableInputDevices = devices 296 | return .none 297 | 298 | case let .toggleSaveTranscriptionHistory(enabled): 299 | state.$hexSettings.withLock { $0.saveTranscriptionHistory = enabled } 300 | 301 | // If disabling history, delete all existing entries 302 | if !enabled { 303 | let transcripts = state.transcriptionHistory.history 304 | 305 | // Clear the history 306 | state.$transcriptionHistory.withLock { history in 307 | history.history.removeAll() 308 | } 309 | 310 | // Delete all audio files 311 | return .run { _ in 312 | for transcript in transcripts { 313 | try? FileManager.default.removeItem(at: transcript.audioPath) 314 | } 315 | } 316 | } 317 | 318 | return .none 319 | } 320 | } 321 | } 322 | } 323 | 324 | // MARK: - Permissions Helpers 325 | 326 | /// Check current microphone permission 327 | private func checkMicrophonePermission() async -> PermissionStatus { 328 | switch AVCaptureDevice.authorizationStatus(for: .audio) { 329 | case .authorized: 330 | return .granted 331 | case .denied, .restricted: 332 | return .denied 333 | case .notDetermined: 334 | return .notDetermined 335 | @unknown default: 336 | return .denied 337 | } 338 | } 339 | 340 | /// Request microphone permission 341 | private func requestMicrophonePermissionImpl() async -> Bool { 342 | await withCheckedContinuation { continuation in 343 | AVCaptureDevice.requestAccess(for: .audio) { granted in 344 | continuation.resume(returning: granted) 345 | } 346 | } 347 | } 348 | 349 | /// Check Accessibility permission on macOS 350 | /// This implementation checks the actual trust status without showing a prompt 351 | private func checkAccessibilityPermission() -> PermissionStatus { 352 | let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary 353 | let trusted = AXIsProcessTrustedWithOptions(options) 354 | return trusted ? .granted : .denied 355 | } 356 | 357 | // MARK: - Permission Status 358 | 359 | enum PermissionStatus: Equatable { 360 | case notDetermined 361 | case granted 362 | case denied 363 | } 364 | -------------------------------------------------------------------------------- /Hex/Features/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Inject 3 | import SwiftUI 4 | 5 | struct SettingsView: View { 6 | @ObserveInjection var inject 7 | @Bindable var store: StoreOf 8 | 9 | var body: some View { 10 | Form { 11 | // --- Permissions Section --- 12 | Section { 13 | // Microphone 14 | HStack { 15 | Label("Microphone", systemImage: "mic.fill") 16 | Spacer() 17 | switch store.microphonePermission { 18 | case .granted: 19 | Label("Granted", systemImage: "checkmark.circle.fill") 20 | .foregroundColor(.green) 21 | .labelStyle(.iconOnly) 22 | case .denied: 23 | Button("Request Permission") { 24 | store.send(.requestMicrophonePermission) 25 | } 26 | .buttonStyle(.borderedProminent) 27 | .tint(.blue) 28 | case .notDetermined: 29 | Button("Request Permission") { 30 | store.send(.requestMicrophonePermission) 31 | } 32 | .buttonStyle(.bordered) 33 | } 34 | } 35 | 36 | // Accessibility 37 | HStack { 38 | Label("Accessibility", systemImage: "accessibility") 39 | Spacer() 40 | switch store.accessibilityPermission { 41 | case .granted: 42 | Label("Granted", systemImage: "checkmark.circle.fill") 43 | .foregroundColor(.green) 44 | .labelStyle(.iconOnly) 45 | case .denied: 46 | Button("Request Permission") { 47 | store.send(.requestAccessibilityPermission) 48 | } 49 | .buttonStyle(.borderedProminent) 50 | .tint(.blue) 51 | case .notDetermined: 52 | Button("Request Permission") { 53 | store.send(.requestAccessibilityPermission) 54 | } 55 | .buttonStyle(.bordered) 56 | } 57 | } 58 | 59 | } header: { 60 | Text("Permissions") 61 | } footer: { 62 | Text("Ensure Hex can access your microphone and system accessibility features.") 63 | .font(.footnote) 64 | .foregroundColor(.secondary) 65 | } 66 | 67 | // --- Input Device Selection Section --- 68 | if store.microphonePermission == .granted && !store.availableInputDevices.isEmpty { 69 | Section { 70 | // Input device picker 71 | HStack { 72 | Label { 73 | Picker("Input Device", selection: $store.hexSettings.selectedMicrophoneID) { 74 | Text("System Default").tag(nil as String?) 75 | ForEach(store.availableInputDevices) { device in 76 | Text(device.name).tag(device.id as String?) 77 | } 78 | } 79 | .pickerStyle(.menu) 80 | .id(UUID()) // Force refresh when devices change 81 | } icon: { 82 | Image(systemName: "mic.circle") 83 | } 84 | 85 | Button(action: { 86 | store.send(.loadAvailableInputDevices) 87 | }) { 88 | Image(systemName: "arrow.clockwise") 89 | } 90 | .buttonStyle(.borderless) 91 | .help("Refresh available input devices") 92 | } 93 | 94 | // Show fallback note for selected device not connected 95 | if let selectedID = store.hexSettings.selectedMicrophoneID, 96 | !store.availableInputDevices.contains(where: { $0.id == selectedID }) 97 | { 98 | Text("Selected device not connected. System default will be used.") 99 | .font(.caption) 100 | .foregroundColor(.secondary) 101 | } 102 | } header: { 103 | Text("Microphone Selection") 104 | } footer: { 105 | Text("Override the system default microphone with a specific input device. This setting will persist across sessions.") 106 | .font(.footnote) 107 | .foregroundColor(.secondary) 108 | } 109 | } 110 | 111 | // --- Transcription Model Section --- 112 | Section("Transcription Model") { 113 | ModelDownloadView(store: store.scope(state: \.modelDownload, action: \.modelDownload) 114 | ) 115 | } 116 | 117 | Label { 118 | Picker("Output Language", selection: $store.hexSettings.outputLanguage) { 119 | ForEach(store.languages, id: \.id) { language in 120 | Text(language.name).tag(language.code) 121 | } 122 | } 123 | .pickerStyle(.menu) 124 | } icon: { 125 | Image(systemName: "globe") 126 | } 127 | 128 | // --- Hot Key Section --- 129 | Section("Hot Key") { 130 | let hotKey = store.hexSettings.hotkey 131 | let key = store.isSettingHotKey ? nil : hotKey.key 132 | let modifiers = store.isSettingHotKey ? store.currentModifiers : hotKey.modifiers 133 | 134 | VStack(spacing: 12) { 135 | // Info text for full keyboard shortcut support 136 | if hotKey.key != nil { 137 | Text("You're using a full keyboard shortcut. Double-tap is recommended.") 138 | .font(.caption) 139 | .foregroundColor(.secondary) 140 | .frame(maxWidth: .infinity, alignment: .center) 141 | } 142 | 143 | // Hot key view 144 | HStack { 145 | Spacer() 146 | HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingHotKey) 147 | .animation(.spring(), value: key) 148 | .animation(.spring(), value: modifiers) 149 | Spacer() 150 | } 151 | .contentShape(Rectangle()) 152 | .onTapGesture { 153 | store.send(.startSettingHotKey) 154 | } 155 | } 156 | 157 | // Double-tap toggle (for key+modifier combinations) 158 | if hotKey.key != nil { 159 | Label { 160 | Toggle("Use double-tap only", isOn: $store.hexSettings.useDoubleTapOnly) 161 | Text("Recommended for custom hotkeys to avoid interfering with normal usage") 162 | .font(.caption) 163 | .foregroundColor(.secondary) 164 | } icon: { 165 | Image(systemName: "hand.tap") 166 | } 167 | } 168 | 169 | // Minimum key time (for modifier-only shortcuts) 170 | if store.hexSettings.hotkey.key == nil { 171 | Label { 172 | Slider(value: $store.hexSettings.minimumKeyTime, in: 0.0 ... 2.0, step: 0.1) { 173 | Text("Ignore below \(store.hexSettings.minimumKeyTime, specifier: "%.1f")s") 174 | } 175 | } icon: { 176 | Image(systemName: "clock") 177 | } 178 | } 179 | } 180 | 181 | // --- Sound Section --- 182 | Section { 183 | Label { 184 | Toggle("Sound Effects", isOn: $store.hexSettings.soundEffectsEnabled) 185 | } icon: { 186 | Image(systemName: "speaker.wave.2.fill") 187 | } 188 | } header: { 189 | Text("Sound") 190 | } 191 | 192 | // --- General Section --- 193 | Section { 194 | Label { 195 | Toggle("Open on Login", 196 | isOn: Binding( 197 | get: { store.hexSettings.openOnLogin }, 198 | set: { store.send(.toggleOpenOnLogin($0)) } 199 | )) 200 | } icon: { 201 | Image(systemName: "arrow.right.circle") 202 | } 203 | 204 | Label { 205 | Toggle("Show Dock Icon", isOn: $store.hexSettings.showDockIcon) 206 | } icon: { 207 | Image(systemName: "dock.rectangle") 208 | } 209 | 210 | Label { 211 | Toggle("Use clipboard to insert", isOn: $store.hexSettings.useClipboardPaste) 212 | Text("Use clipboard to insert text. Fast but may not restore all clipboard content.\nTurn off to use simulated keypresses. Slower, but doesn't need to restore clipboard") 213 | } icon: { 214 | Image(systemName: "doc.on.doc.fill") 215 | } 216 | 217 | Label { 218 | Toggle("Copy to clipboard", isOn: $store.hexSettings.copyToClipboard) 219 | Text("Copy transcription text to clipboard in addition to pasting it") 220 | } icon: { 221 | Image(systemName: "doc.on.clipboard") 222 | } 223 | 224 | Label { 225 | Toggle( 226 | "Prevent System Sleep while Recording", 227 | isOn: Binding( 228 | get: { store.hexSettings.preventSystemSleep }, 229 | set: { store.send(.togglePreventSystemSleep($0)) } 230 | ) 231 | ) 232 | } icon: { 233 | Image(systemName: "zzz") 234 | } 235 | 236 | Label { 237 | Toggle( 238 | "Pause Media while Recording", 239 | isOn: Binding( 240 | get: { store.hexSettings.pauseMediaOnRecord }, 241 | set: { store.send(.togglePauseMediaOnRecord($0)) } 242 | ) 243 | ) 244 | } icon: { 245 | Image(systemName: "pause") 246 | } 247 | } header: { 248 | Text("General") 249 | } 250 | 251 | // --- History Section --- 252 | Section { 253 | Label { 254 | Toggle("Save Transcription History", isOn: Binding( 255 | get: { store.hexSettings.saveTranscriptionHistory }, 256 | set: { store.send(.toggleSaveTranscriptionHistory($0)) } 257 | )) 258 | Text("Save transcriptions and audio recordings for later access") 259 | .font(.caption) 260 | .foregroundColor(.secondary) 261 | } icon: { 262 | Image(systemName: "clock.arrow.circlepath") 263 | } 264 | 265 | if store.hexSettings.saveTranscriptionHistory { 266 | Label { 267 | HStack { 268 | Text("Maximum History Entries") 269 | Spacer() 270 | Picker("", selection: Binding( 271 | get: { store.hexSettings.maxHistoryEntries ?? 0 }, 272 | set: { newValue in 273 | store.hexSettings.maxHistoryEntries = newValue == 0 ? nil : newValue 274 | } 275 | )) { 276 | Text("Unlimited").tag(0) 277 | Text("50").tag(50) 278 | Text("100").tag(100) 279 | Text("200").tag(200) 280 | Text("500").tag(500) 281 | Text("1000").tag(1000) 282 | } 283 | .pickerStyle(.menu) 284 | .frame(width: 120) 285 | } 286 | } icon: { 287 | Image(systemName: "number.square") 288 | } 289 | 290 | if store.hexSettings.maxHistoryEntries != nil { 291 | Text("Oldest entries will be automatically deleted when limit is reached") 292 | .font(.caption) 293 | .foregroundColor(.secondary) 294 | .padding(.leading, 28) 295 | } 296 | } 297 | } header: { 298 | Text("History") 299 | } footer: { 300 | if !store.hexSettings.saveTranscriptionHistory { 301 | Text("When disabled, transcriptions will not be saved and audio files will be deleted immediately after transcription.") 302 | .font(.footnote) 303 | .foregroundColor(.secondary) 304 | } 305 | } 306 | } 307 | .formStyle(.grouped) 308 | .task { 309 | await store.send(.task).finish() 310 | } 311 | .enableInjection() 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /Hex/Features/Transcription/HotKeyProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotKeyProcessor.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/28/25. 6 | // 7 | import Dependencies 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// Implements both "Press-and-Hold" and "Double-Tap Lock" in a single state machine. 12 | /// 13 | /// Double-tap logic: 14 | /// - A "tap" is recognized if we see chord == hotkey => chord != hotkey quickly. 15 | /// - We track the **release time** of each tap in `lastTapAt`. 16 | /// - On the second release, if the time since the prior release is < `doubleTapThreshold`, 17 | /// we switch to .doubleTapLock instead of stopping. 18 | /// (No new .startRecording output — we remain in a locked recording state.) 19 | /// 20 | /// Press-and-Hold logic remains the same: 21 | /// - If chord == hotkey while idle => .startRecording => state=.pressAndHold. 22 | /// - If, within 1 second, the user changes chord => .stopRecording => idle => dirty 23 | /// so we don't instantly re-match mid-press. 24 | /// - If the user "releases" the hotkey chord => .stopRecording => idle => track release time. 25 | /// That release time is used to detect a second quick tap => possible doubleTapLock. 26 | /// 27 | /// Additional details: 28 | /// - For modifier-only hotkeys, “release” is chord = (key:nil, modifiers:[]). 29 | /// - Pressing ESC => immediate .cancel => resetToIdle(). 30 | /// - "Dirty" logic is unchanged from the prior iteration, so we still ignore any chord 31 | /// until the user fully releases (key:nil, modifiers:[]). 32 | 33 | public struct HotKeyProcessor { 34 | @Dependency(\.date.now) var now 35 | 36 | public var hotkey: HotKey 37 | public var useDoubleTapOnly: Bool = false 38 | 39 | public private(set) var state: State = .idle 40 | private var lastTapAt: Date? // Time of the most recent release 41 | private var isDirty: Bool = false 42 | 43 | public static let doubleTapThreshold: TimeInterval = 0.3 44 | public static let pressAndHoldCancelThreshold: TimeInterval = 1.0 45 | 46 | public init(hotkey: HotKey, useDoubleTapOnly: Bool = false) { 47 | self.hotkey = hotkey 48 | self.useDoubleTapOnly = useDoubleTapOnly 49 | } 50 | 51 | public var isMatched: Bool { 52 | switch state { 53 | case .idle: 54 | return false 55 | case .pressAndHold, .doubleTapLock: 56 | return true 57 | } 58 | } 59 | 60 | public mutating func process(keyEvent: KeyEvent) -> Output? { 61 | // 1) ESC => immediate cancel 62 | if keyEvent.key == .escape { 63 | print("ESCAPE HIT IN STATE: \(state)") 64 | } 65 | if keyEvent.key == .escape, state != .idle { 66 | resetToIdle() 67 | return .cancel 68 | } 69 | 70 | // 2) If dirty, ignore until full release (nil, []) 71 | if isDirty { 72 | if chordIsFullyReleased(keyEvent) { 73 | isDirty = false 74 | } else { 75 | return nil 76 | } 77 | } 78 | 79 | // 3) Matching chord => handle as "press" 80 | if chordMatchesHotkey(keyEvent) { 81 | return handleMatchingChord() 82 | } else { 83 | // Potentially become dirty if chord has extra mods or different key 84 | if chordIsDirty(keyEvent) { 85 | isDirty = true 86 | } 87 | return handleNonmatchingChord(keyEvent) 88 | } 89 | } 90 | } 91 | 92 | // MARK: - State & Output 93 | 94 | public extension HotKeyProcessor { 95 | enum State: Equatable { 96 | case idle 97 | case pressAndHold(startTime: Date) 98 | case doubleTapLock 99 | } 100 | 101 | enum Output: Equatable { 102 | case startRecording 103 | case stopRecording 104 | case cancel 105 | } 106 | } 107 | 108 | // MARK: - Core Logic 109 | 110 | extension HotKeyProcessor { 111 | /// If we are idle and see chord == hotkey => pressAndHold (or potentially normal). 112 | /// We do *not* lock on second press. That is deferred until the second release. 113 | private mutating func handleMatchingChord() -> Output? { 114 | switch state { 115 | case .idle: 116 | // If doubleTapOnly mode is enabled and the hotkey has a key component, 117 | // we want to delay starting recording until we see the double-tap 118 | if useDoubleTapOnly && hotkey.key != nil { 119 | // Record the timestamp but don't start recording 120 | lastTapAt = now 121 | return nil 122 | } else { 123 | // Normal press => .pressAndHold => .startRecording 124 | state = .pressAndHold(startTime: now) 125 | return .startRecording 126 | } 127 | 128 | case .pressAndHold: 129 | // Already matched, no new output 130 | return nil 131 | 132 | case .doubleTapLock: 133 | // Pressing hotkey again while locked => stop 134 | resetToIdle() 135 | return .stopRecording 136 | } 137 | } 138 | 139 | /// Called when chord != hotkey. We check if user is "releasing" or "typing something else". 140 | private mutating func handleNonmatchingChord(_ e: KeyEvent) -> Output? { 141 | switch state { 142 | case .idle: 143 | // Handle double-tap detection for key+modifier combinations 144 | if useDoubleTapOnly && hotkey.key != nil && 145 | chordIsFullyReleased(e) && 146 | lastTapAt != nil { 147 | // If we've seen a tap recently, and now we see a full release, and we're in idle state 148 | // Check if the time between taps is within the threshold 149 | if let prevTapTime = lastTapAt, 150 | now.timeIntervalSince(prevTapTime) < Self.doubleTapThreshold { 151 | // This is the second tap - activate recording in double-tap lock mode 152 | state = .doubleTapLock 153 | return .startRecording 154 | } 155 | 156 | // Reset the tap timer as we've fully released 157 | lastTapAt = nil 158 | } 159 | return nil 160 | 161 | case let .pressAndHold(startTime): 162 | // If user truly "released" the chord => either normal stop or doubleTapLock 163 | if isReleaseForActiveHotkey(e) { 164 | // Check if this release is close to the prior release => double-tap lock 165 | if let prevReleaseTime = lastTapAt, 166 | now.timeIntervalSince(prevReleaseTime) < Self.doubleTapThreshold 167 | { 168 | // => Switch to doubleTapLock, remain matched, no new output 169 | state = .doubleTapLock 170 | return nil 171 | } else { 172 | // Normal stop => idle => record the release time 173 | state = .idle 174 | lastTapAt = now 175 | return .stopRecording 176 | } 177 | } else { 178 | // If within 1s, treat as cancel hold => stop => become dirty 179 | let elapsed = now.timeIntervalSince(startTime) 180 | if elapsed < Self.pressAndHoldCancelThreshold { 181 | isDirty = true 182 | resetToIdle() 183 | return .stopRecording 184 | } else { 185 | // After 1s => remain matched 186 | return nil 187 | } 188 | } 189 | 190 | case .doubleTapLock: 191 | // For key+modifier combinations in doubleTapLock mode, require full key release to stop 192 | if useDoubleTapOnly && hotkey.key != nil && chordIsFullyReleased(e) { 193 | resetToIdle() 194 | return .stopRecording 195 | } 196 | // Otherwise, if locked, ignore everything except chord == hotkey => stop 197 | return nil 198 | } 199 | } 200 | 201 | // MARK: - Helpers 202 | 203 | private func chordMatchesHotkey(_ e: KeyEvent) -> Bool { 204 | // For hotkeys that include a key, both the key and modifiers must match exactly 205 | if hotkey.key != nil { 206 | return e.key == hotkey.key && e.modifiers == hotkey.modifiers 207 | } else { 208 | // For modifier-only hotkeys, we just check that all required modifiers are present 209 | // This allows other modifiers to be pressed without affecting the match 210 | return hotkey.modifiers.isSubset(of: e.modifiers) 211 | } 212 | } 213 | 214 | /// "Dirty" if chord includes any extra modifiers or a different key. 215 | private func chordIsDirty(_ e: KeyEvent) -> Bool { 216 | let isSubset = e.modifiers.isSubset(of: hotkey.modifiers) 217 | let isWrongKey = (hotkey.key != nil && e.key != nil && e.key != hotkey.key) 218 | return !isSubset || isWrongKey 219 | } 220 | 221 | private func chordIsFullyReleased(_ e: KeyEvent) -> Bool { 222 | e.key == nil && e.modifiers.isEmpty 223 | } 224 | 225 | /// For a key+modifier hotkey, "release" => same modifiers, no key. 226 | /// For a modifier-only hotkey, "release" => no modifiers at all. 227 | private func isReleaseForActiveHotkey(_ e: KeyEvent) -> Bool { 228 | if hotkey.key != nil { 229 | // For key+modifier hotkeys, we need to check: 230 | // 1. Key is released (key == nil) 231 | // 2. Modifiers match exactly what was in the hotkey 232 | return e.key == nil && e.modifiers == hotkey.modifiers 233 | } else { 234 | // For modifier-only hotkeys, we check: 235 | // 1. Key is nil 236 | // 2. Required hotkey modifiers are no longer pressed 237 | // This detects when user has released the specific modifiers in the hotkey 238 | return e.key == nil && !hotkey.modifiers.isSubset(of: e.modifiers) 239 | } 240 | } 241 | 242 | /// Clear state but preserve `isDirty` if the caller has just set it. 243 | private mutating func resetToIdle() { 244 | state = .idle 245 | lastTapAt = nil 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /Hex/Features/Transcription/TranscriptionFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TranscriptionFeature.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/24/25. 6 | // 7 | 8 | import ComposableArchitecture 9 | import CoreGraphics 10 | import Inject 11 | import SwiftUI 12 | import WhisperKit 13 | import IOKit 14 | import IOKit.pwr_mgt 15 | 16 | @Reducer 17 | struct TranscriptionFeature { 18 | @ObservableState 19 | struct State { 20 | var isRecording: Bool = false 21 | var isTranscribing: Bool = false 22 | var isPrewarming: Bool = false 23 | var error: String? 24 | var recordingStartTime: Date? 25 | var meter: Meter = .init(averagePower: 0, peakPower: 0) 26 | var assertionID: IOPMAssertionID? 27 | @Shared(.hexSettings) var hexSettings: HexSettings 28 | @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory 29 | } 30 | 31 | enum Action { 32 | case task 33 | case audioLevelUpdated(Meter) 34 | 35 | // Hotkey actions 36 | case hotKeyPressed 37 | case hotKeyReleased 38 | 39 | // Recording flow 40 | case startRecording 41 | case stopRecording 42 | 43 | // Cancel entire flow 44 | case cancel 45 | 46 | // Transcription result flow 47 | case transcriptionResult(String) 48 | case transcriptionError(Error) 49 | } 50 | 51 | enum CancelID { 52 | case delayedRecord 53 | case metering 54 | case transcription 55 | } 56 | 57 | @Dependency(\.transcription) var transcription 58 | @Dependency(\.recording) var recording 59 | @Dependency(\.pasteboard) var pasteboard 60 | @Dependency(\.keyEventMonitor) var keyEventMonitor 61 | @Dependency(\.soundEffects) var soundEffect 62 | 63 | var body: some ReducerOf { 64 | Reduce { state, action in 65 | switch action { 66 | // MARK: - Lifecycle / Setup 67 | 68 | case .task: 69 | // Starts two concurrent effects: 70 | // 1) Observing audio meter 71 | // 2) Monitoring hot key events 72 | return .merge( 73 | startMeteringEffect(), 74 | startHotKeyMonitoringEffect() 75 | ) 76 | 77 | // MARK: - Metering 78 | 79 | case let .audioLevelUpdated(meter): 80 | state.meter = meter 81 | return .none 82 | 83 | // MARK: - HotKey Flow 84 | 85 | case .hotKeyPressed: 86 | // If we're transcribing, send a cancel first. Then queue up a 87 | // "startRecording" in 200ms if the user keeps holding the hotkey. 88 | return handleHotKeyPressed(isTranscribing: state.isTranscribing) 89 | 90 | case .hotKeyReleased: 91 | // If we’re currently recording, then stop. Otherwise, just cancel 92 | // the delayed “startRecording” effect if we never actually started. 93 | return handleHotKeyReleased(isRecording: state.isRecording) 94 | 95 | // MARK: - Recording Flow 96 | 97 | case .startRecording: 98 | return handleStartRecording(&state) 99 | 100 | case .stopRecording: 101 | return handleStopRecording(&state) 102 | 103 | // MARK: - Transcription Results 104 | 105 | case let .transcriptionResult(result): 106 | return handleTranscriptionResult(&state, result: result) 107 | 108 | case let .transcriptionError(error): 109 | return handleTranscriptionError(&state, error: error) 110 | 111 | // MARK: - Cancel Entire Flow 112 | 113 | case .cancel: 114 | // Only cancel if we’re in the middle of recording or transcribing 115 | guard state.isRecording || state.isTranscribing else { 116 | return .none 117 | } 118 | return handleCancel(&state) 119 | } 120 | } 121 | } 122 | } 123 | 124 | // MARK: - Effects: Metering & HotKey 125 | 126 | private extension TranscriptionFeature { 127 | /// Effect to begin observing the audio meter. 128 | func startMeteringEffect() -> Effect { 129 | .run { send in 130 | for await meter in await recording.observeAudioLevel() { 131 | await send(.audioLevelUpdated(meter)) 132 | } 133 | } 134 | .cancellable(id: CancelID.metering, cancelInFlight: true) 135 | } 136 | 137 | /// Effect to start monitoring hotkey events through the `keyEventMonitor`. 138 | func startHotKeyMonitoringEffect() -> Effect { 139 | .run { send in 140 | var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option])) 141 | @Shared(.isSettingHotKey) var isSettingHotKey: Bool 142 | @Shared(.hexSettings) var hexSettings: HexSettings 143 | 144 | // Handle incoming key events 145 | keyEventMonitor.handleKeyEvent { keyEvent in 146 | // Skip if the user is currently setting a hotkey 147 | if isSettingHotKey { 148 | return false 149 | } 150 | 151 | // If Escape is pressed with no modifiers while idle, let’s treat that as `cancel`. 152 | if keyEvent.key == .escape, keyEvent.modifiers.isEmpty, 153 | hotKeyProcessor.state == .idle 154 | { 155 | Task { await send(.cancel) } 156 | return false 157 | } 158 | 159 | // Always keep hotKeyProcessor in sync with current user hotkey preference 160 | hotKeyProcessor.hotkey = hexSettings.hotkey 161 | hotKeyProcessor.useDoubleTapOnly = hexSettings.useDoubleTapOnly 162 | 163 | // Process the key event 164 | switch hotKeyProcessor.process(keyEvent: keyEvent) { 165 | case .startRecording: 166 | // If double-tap lock is triggered, we start recording immediately 167 | if hotKeyProcessor.state == .doubleTapLock { 168 | Task { await send(.startRecording) } 169 | } else { 170 | Task { await send(.hotKeyPressed) } 171 | } 172 | // If the hotkey is purely modifiers, return false to keep it from interfering with normal usage 173 | // But if useDoubleTapOnly is true, always intercept the key 174 | return hexSettings.useDoubleTapOnly || keyEvent.key != nil 175 | 176 | case .stopRecording: 177 | Task { await send(.hotKeyReleased) } 178 | return false // or `true` if you want to intercept 179 | 180 | case .cancel: 181 | Task { await send(.cancel) } 182 | return true 183 | 184 | case .none: 185 | // If we detect repeated same chord, maybe intercept. 186 | if let pressedKey = keyEvent.key, 187 | pressedKey == hotKeyProcessor.hotkey.key, 188 | keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers 189 | { 190 | return true 191 | } 192 | return false 193 | } 194 | } 195 | } 196 | } 197 | } 198 | 199 | // MARK: - HotKey Press/Release Handlers 200 | 201 | private extension TranscriptionFeature { 202 | func handleHotKeyPressed(isTranscribing: Bool) -> Effect { 203 | let maybeCancel = isTranscribing ? Effect.send(Action.cancel) : .none 204 | 205 | // We wait 200ms before actually sending `.startRecording` 206 | // so the user can do a quick press => do something else 207 | // (like a double-tap). 208 | let delayedStart = Effect.run { send in 209 | try await Task.sleep(for: .milliseconds(200)) 210 | await send(Action.startRecording) 211 | } 212 | .cancellable(id: CancelID.delayedRecord, cancelInFlight: true) 213 | 214 | return .merge(maybeCancel, delayedStart) 215 | } 216 | 217 | func handleHotKeyReleased(isRecording: Bool) -> Effect { 218 | if isRecording { 219 | // We actually stop if we’re currently recording 220 | return .send(.stopRecording) 221 | } else { 222 | // If not recording yet, just cancel the delayed start 223 | return .cancel(id: CancelID.delayedRecord) 224 | } 225 | } 226 | } 227 | 228 | // MARK: - Recording Handlers 229 | 230 | private extension TranscriptionFeature { 231 | func handleStartRecording(_ state: inout State) -> Effect { 232 | state.isRecording = true 233 | state.recordingStartTime = Date() 234 | 235 | // Prevent system sleep during recording 236 | if state.hexSettings.preventSystemSleep { 237 | preventSystemSleep(&state) 238 | } 239 | 240 | return .run { _ in 241 | await recording.startRecording() 242 | await soundEffect.play(.startRecording) 243 | } 244 | } 245 | 246 | func handleStopRecording(_ state: inout State) -> Effect { 247 | state.isRecording = false 248 | 249 | // Allow system to sleep again by releasing the power management assertion 250 | // Always call this, even if the setting is off, to ensure we don’t leak assertions 251 | // (e.g. if the setting was toggled off mid-recording) 252 | reallowSystemSleep(&state) 253 | 254 | let durationIsLongEnough: Bool = { 255 | guard let startTime = state.recordingStartTime else { return false } 256 | return Date().timeIntervalSince(startTime) > state.hexSettings.minimumKeyTime 257 | }() 258 | 259 | guard (durationIsLongEnough && state.hexSettings.hotkey.key == nil) else { 260 | // If the user recorded for less than minimumKeyTime, just discard 261 | // unless the hotkey includes a regular key, in which case, we can assume it was intentional 262 | print("Recording was too short, discarding") 263 | return .run { _ in 264 | _ = await recording.stopRecording() 265 | } 266 | } 267 | 268 | // Otherwise, proceed to transcription 269 | state.isTranscribing = true 270 | state.error = nil 271 | let model = state.hexSettings.selectedModel 272 | let language = state.hexSettings.outputLanguage 273 | 274 | state.isPrewarming = true 275 | 276 | return .run { send in 277 | do { 278 | await soundEffect.play(.stopRecording) 279 | let audioURL = await recording.stopRecording() 280 | 281 | // Create transcription options with the selected language 282 | let decodeOptions = DecodingOptions( 283 | language: language, 284 | detectLanguage: language == nil, // Only auto-detect if no language specified 285 | chunkingStrategy: .vad 286 | ) 287 | 288 | let result = try await transcription.transcribe(audioURL, model, decodeOptions) { _ in } 289 | 290 | print("Transcribed audio from URL: \(audioURL) to text: \(result)") 291 | await send(.transcriptionResult(result)) 292 | } catch { 293 | print("Error transcribing audio: \(error)") 294 | await send(.transcriptionError(error)) 295 | } 296 | } 297 | .cancellable(id: CancelID.transcription) 298 | } 299 | } 300 | 301 | // MARK: - Transcription Handlers 302 | 303 | private extension TranscriptionFeature { 304 | func handleTranscriptionResult( 305 | _ state: inout State, 306 | result: String 307 | ) -> Effect { 308 | state.isTranscribing = false 309 | state.isPrewarming = false 310 | 311 | // If empty text, nothing else to do 312 | guard !result.isEmpty else { 313 | return .none 314 | } 315 | 316 | // Compute how long we recorded 317 | let duration = state.recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0 318 | 319 | // Continue with storing the final result in the background 320 | return finalizeRecordingAndStoreTranscript( 321 | result: result, 322 | duration: duration, 323 | transcriptionHistory: state.$transcriptionHistory 324 | ) 325 | } 326 | 327 | func handleTranscriptionError( 328 | _ state: inout State, 329 | error: Error 330 | ) -> Effect { 331 | state.isTranscribing = false 332 | state.isPrewarming = false 333 | state.error = error.localizedDescription 334 | 335 | return .run { _ in 336 | await soundEffect.play(.cancel) 337 | } 338 | } 339 | 340 | /// Move file to permanent location, create a transcript record, paste text, and play sound. 341 | func finalizeRecordingAndStoreTranscript( 342 | result: String, 343 | duration: TimeInterval, 344 | transcriptionHistory: Shared 345 | ) -> Effect { 346 | .run { send in 347 | do { 348 | let originalURL = await recording.stopRecording() 349 | 350 | @Shared(.hexSettings) var hexSettings: HexSettings 351 | 352 | // Check if we should save to history 353 | if hexSettings.saveTranscriptionHistory { 354 | // Move the file to a permanent location 355 | let fm = FileManager.default 356 | let supportDir = try fm.url( 357 | for: .applicationSupportDirectory, 358 | in: .userDomainMask, 359 | appropriateFor: nil, 360 | create: true 361 | ) 362 | let ourAppFolder = supportDir.appendingPathComponent("com.kitlangton.Hex", isDirectory: true) 363 | let recordingsFolder = ourAppFolder.appendingPathComponent("Recordings", isDirectory: true) 364 | try fm.createDirectory(at: recordingsFolder, withIntermediateDirectories: true) 365 | 366 | // Create a unique file name 367 | let filename = "\(Date().timeIntervalSince1970).wav" 368 | let finalURL = recordingsFolder.appendingPathComponent(filename) 369 | 370 | // Move temp => final 371 | try fm.moveItem(at: originalURL, to: finalURL) 372 | 373 | // Build a transcript object 374 | let transcript = Transcript( 375 | timestamp: Date(), 376 | text: result, 377 | audioPath: finalURL, 378 | duration: duration 379 | ) 380 | 381 | // Append to the in-memory shared history 382 | transcriptionHistory.withLock { history in 383 | history.history.insert(transcript, at: 0) 384 | 385 | // Trim history if max entries is set 386 | if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 { 387 | while history.history.count > maxEntries { 388 | if let removedTranscript = history.history.popLast() { 389 | // Delete the audio file 390 | try? FileManager.default.removeItem(at: removedTranscript.audioPath) 391 | } 392 | } 393 | } 394 | } 395 | } else { 396 | // If not saving history, just delete the temp audio file 397 | try? FileManager.default.removeItem(at: originalURL) 398 | } 399 | 400 | // Paste text (and copy if enabled via pasteWithClipboard) 401 | await pasteboard.paste(result) 402 | await soundEffect.play(.pasteTranscript) 403 | } catch { 404 | await send(.transcriptionError(error)) 405 | } 406 | } 407 | } 408 | } 409 | 410 | // MARK: - Cancel Handler 411 | 412 | private extension TranscriptionFeature { 413 | func handleCancel(_ state: inout State) -> Effect { 414 | state.isTranscribing = false 415 | state.isRecording = false 416 | state.isPrewarming = false 417 | 418 | return .merge( 419 | .cancel(id: CancelID.transcription), 420 | .cancel(id: CancelID.delayedRecord), 421 | .run { _ in 422 | await soundEffect.play(.cancel) 423 | } 424 | ) 425 | } 426 | } 427 | 428 | // MARK: - System Sleep Prevention 429 | 430 | private extension TranscriptionFeature { 431 | func preventSystemSleep(_ state: inout State) { 432 | // Prevent system sleep during recording 433 | let reasonForActivity = "Hex Voice Recording" as CFString 434 | var assertionID: IOPMAssertionID = 0 435 | let success = IOPMAssertionCreateWithName( 436 | kIOPMAssertionTypeNoDisplaySleep as CFString, 437 | IOPMAssertionLevel(kIOPMAssertionLevelOn), 438 | reasonForActivity, 439 | &assertionID 440 | ) 441 | if success == kIOReturnSuccess { 442 | state.assertionID = assertionID 443 | } 444 | } 445 | 446 | func reallowSystemSleep(_ state: inout State) { 447 | if let assertionID = state.assertionID { 448 | let releaseSuccess = IOPMAssertionRelease(assertionID) 449 | if releaseSuccess == kIOReturnSuccess { 450 | state.assertionID = nil 451 | } 452 | } 453 | } 454 | } 455 | 456 | // MARK: - View 457 | 458 | struct TranscriptionView: View { 459 | @Bindable var store: StoreOf 460 | @ObserveInjection var inject 461 | 462 | var status: TranscriptionIndicatorView.Status { 463 | if store.isTranscribing { 464 | return .transcribing 465 | } else if store.isRecording { 466 | return .recording 467 | } else if store.isPrewarming { 468 | return .prewarming 469 | } else { 470 | return .hidden 471 | } 472 | } 473 | 474 | var body: some View { 475 | TranscriptionIndicatorView( 476 | status: status, 477 | meter: store.meter 478 | ) 479 | .task { 480 | await store.send(.task).finish() 481 | } 482 | .enableInjection() 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /Hex/Features/Transcription/TranscriptionIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HexCapsuleView.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/25/25. 6 | 7 | import Inject 8 | import Pow 9 | import SwiftUI 10 | 11 | struct TranscriptionIndicatorView: View { 12 | @ObserveInjection var inject 13 | 14 | enum Status { 15 | case hidden 16 | case optionKeyPressed 17 | case recording 18 | case transcribing 19 | case prewarming 20 | } 21 | 22 | var status: Status 23 | var meter: Meter 24 | 25 | let transcribeBaseColor: Color = .blue 26 | 27 | private var backgroundColor: Color { 28 | switch status { 29 | case .hidden: return Color.clear 30 | case .optionKeyPressed: return Color.black 31 | case .recording: return .red.mix(with: .black, by: 0.5).mix(with: .red, by: meter.averagePower * 3) 32 | case .transcribing: return transcribeBaseColor.mix(with: .black, by: 0.5) 33 | case .prewarming: return transcribeBaseColor.mix(with: .black, by: 0.5) 34 | } 35 | } 36 | 37 | private var strokeColor: Color { 38 | switch status { 39 | case .hidden: return Color.clear 40 | case .optionKeyPressed: return Color.black 41 | case .recording: return Color.red.mix(with: .white, by: 0.1).opacity(0.6) 42 | case .transcribing: return transcribeBaseColor.mix(with: .white, by: 0.1).opacity(0.6) 43 | case .prewarming: return transcribeBaseColor.mix(with: .white, by: 0.1).opacity(0.6) 44 | } 45 | } 46 | 47 | private var innerShadowColor: Color { 48 | switch status { 49 | case .hidden: return Color.clear 50 | case .optionKeyPressed: return Color.clear 51 | case .recording: return Color.red 52 | case .transcribing: return transcribeBaseColor 53 | case .prewarming: return transcribeBaseColor 54 | } 55 | } 56 | 57 | private let cornerRadius: CGFloat = 8 58 | private let baseWidth: CGFloat = 16 59 | private let expandedWidth: CGFloat = 56 60 | 61 | var isHidden: Bool { 62 | status == .hidden 63 | } 64 | 65 | @State var transcribeEffect = 0 66 | 67 | var body: some View { 68 | let averagePower = min(1, meter.averagePower * 3) 69 | let peakPower = min(1, meter.peakPower * 3) 70 | ZStack { 71 | Capsule() 72 | .fill(backgroundColor.shadow(.inner(color: innerShadowColor, radius: 4))) 73 | .overlay { 74 | Capsule() 75 | .stroke(strokeColor, lineWidth: 1) 76 | .blendMode(.screen) 77 | } 78 | .overlay(alignment: .center) { 79 | RoundedRectangle(cornerRadius: cornerRadius) 80 | .fill(Color.red.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 1) : 0)) 81 | .blur(radius: 2) 82 | .blendMode(.screen) 83 | .padding(6) 84 | } 85 | .overlay(alignment: .center) { 86 | RoundedRectangle(cornerRadius: cornerRadius) 87 | .fill(Color.white.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 0.5) : 0)) 88 | .blur(radius: 1) 89 | .blendMode(.screen) 90 | .frame(maxWidth: .infinity, alignment: .center) 91 | .padding(7) 92 | } 93 | .overlay(alignment: .center) { 94 | GeometryReader { proxy in 95 | RoundedRectangle(cornerRadius: cornerRadius) 96 | .fill(Color.red.opacity(status == .recording ? (peakPower < 0.1 ? (peakPower / 0.1) * 0.5 : 0.5) : 0)) 97 | .frame(width: max(proxy.size.width * (peakPower + 0.6), 0), height: proxy.size.height, alignment: .center) 98 | .frame(maxWidth: .infinity, alignment: .center) 99 | .blur(radius: 4) 100 | .blendMode(.screen) 101 | }.padding(6) 102 | } 103 | .cornerRadius(cornerRadius) 104 | .shadow( 105 | color: status == .recording ? .red.opacity(averagePower) : .red.opacity(0), 106 | radius: 4 107 | ) 108 | .shadow( 109 | color: status == .recording ? .red.opacity(averagePower * 0.5) : .red.opacity(0), 110 | radius: 8 111 | ) 112 | .animation(.interactiveSpring(), value: meter) 113 | .frame( 114 | width: status == .recording ? expandedWidth : baseWidth, 115 | height: baseWidth 116 | ) 117 | .opacity(status == .hidden ? 0 : 1) 118 | .scaleEffect(status == .hidden ? 0.0 : 1) 119 | .blur(radius: status == .hidden ? 4 : 0) 120 | .animation(.bouncy(duration: 0.3), value: status) 121 | .changeEffect(.glow(color: .red.opacity(0.5), radius: 8), value: status) 122 | .changeEffect(.shine(angle: .degrees(0), duration: 0.6), value: transcribeEffect) 123 | .compositingGroup() 124 | .task(id: status == .transcribing) { 125 | while status == .transcribing, !Task.isCancelled { 126 | transcribeEffect += 1 127 | try? await Task.sleep(for: .seconds(0.25)) 128 | } 129 | } 130 | 131 | // Show tooltip when prewarming 132 | if status == .prewarming { 133 | VStack(spacing: 4) { 134 | Text("Model prewarming...") 135 | .font(.system(size: 12, weight: .medium)) 136 | .foregroundColor(.white) 137 | .padding(.horizontal, 8) 138 | .padding(.vertical, 4) 139 | .background( 140 | RoundedRectangle(cornerRadius: 4) 141 | .fill(Color.black.opacity(0.8)) 142 | ) 143 | } 144 | .offset(y: -24) 145 | .transition(.opacity) 146 | .zIndex(2) 147 | } 148 | } 149 | .enableInjection() 150 | } 151 | } 152 | 153 | #Preview("HEX") { 154 | VStack(spacing: 8) { 155 | TranscriptionIndicatorView(status: .hidden, meter: .init(averagePower: 0, peakPower: 0)) 156 | TranscriptionIndicatorView(status: .optionKeyPressed, meter: .init(averagePower: 0, peakPower: 0)) 157 | TranscriptionIndicatorView(status: .recording, meter: .init(averagePower: 0.5, peakPower: 0.5)) 158 | TranscriptionIndicatorView(status: .transcribing, meter: .init(averagePower: 0, peakPower: 0)) 159 | TranscriptionIndicatorView(status: .prewarming, meter: .init(averagePower: 0, peakPower: 0)) 160 | } 161 | .padding(40) 162 | } 163 | -------------------------------------------------------------------------------- /Hex/Hex.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.device.audio-input 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Hex/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleShortVersionString 6 | 0.2.5 7 | CFBundleVersion 8 | 38 9 | NSAccessibilityUsageDescription 10 | Hex needs accessibility access to monitor keyboard events for hotkey detection. 11 | NSAppTransportSecurity 12 | 13 | NSAllowsArbitraryLoads 14 | 15 | 16 | SUFeedURL 17 | https://hex-updates.s3.amazonaws.com/appcast.xml 18 | SUPublicEDKey 19 | mIek27lttJe8cIBqVZFhh6reRKjpTx1h9ZY9OKWPtuM= 20 | 21 | 22 | -------------------------------------------------------------------------------- /Hex/Models/HexSettings.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Dependencies 3 | import Foundation 4 | 5 | // To add a new setting, add a new property to the struct, the CodingKeys enum, and the custom decoder 6 | struct HexSettings: Codable, Equatable { 7 | var soundEffectsEnabled: Bool = true 8 | var hotkey: HotKey = .init(key: nil, modifiers: [.option]) 9 | var openOnLogin: Bool = false 10 | var showDockIcon: Bool = true 11 | var selectedModel: String = "openai_whisper-large-v3-v20240930" 12 | var useClipboardPaste: Bool = true 13 | var preventSystemSleep: Bool = true 14 | var pauseMediaOnRecord: Bool = true 15 | var minimumKeyTime: Double = 0.2 16 | var copyToClipboard: Bool = false 17 | var useDoubleTapOnly: Bool = false 18 | var outputLanguage: String? = nil 19 | var selectedMicrophoneID: String? = nil 20 | var saveTranscriptionHistory: Bool = true 21 | var maxHistoryEntries: Int? = nil 22 | 23 | // Define coding keys to match struct properties 24 | enum CodingKeys: String, CodingKey { 25 | case soundEffectsEnabled 26 | case hotkey 27 | case openOnLogin 28 | case showDockIcon 29 | case selectedModel 30 | case useClipboardPaste 31 | case preventSystemSleep 32 | case pauseMediaOnRecord 33 | case minimumKeyTime 34 | case copyToClipboard 35 | case useDoubleTapOnly 36 | case outputLanguage 37 | case selectedMicrophoneID 38 | case saveTranscriptionHistory 39 | case maxHistoryEntries 40 | } 41 | 42 | init( 43 | soundEffectsEnabled: Bool = true, 44 | hotkey: HotKey = .init(key: nil, modifiers: [.option]), 45 | openOnLogin: Bool = false, 46 | showDockIcon: Bool = true, 47 | selectedModel: String = "openai_whisper-large-v3-v20240930", 48 | useClipboardPaste: Bool = true, 49 | preventSystemSleep: Bool = true, 50 | pauseMediaOnRecord: Bool = true, 51 | minimumKeyTime: Double = 0.2, 52 | copyToClipboard: Bool = false, 53 | useDoubleTapOnly: Bool = false, 54 | outputLanguage: String? = nil, 55 | selectedMicrophoneID: String? = nil, 56 | saveTranscriptionHistory: Bool = true, 57 | maxHistoryEntries: Int? = nil 58 | ) { 59 | self.soundEffectsEnabled = soundEffectsEnabled 60 | self.hotkey = hotkey 61 | self.openOnLogin = openOnLogin 62 | self.showDockIcon = showDockIcon 63 | self.selectedModel = selectedModel 64 | self.useClipboardPaste = useClipboardPaste 65 | self.preventSystemSleep = preventSystemSleep 66 | self.pauseMediaOnRecord = pauseMediaOnRecord 67 | self.minimumKeyTime = minimumKeyTime 68 | self.copyToClipboard = copyToClipboard 69 | self.useDoubleTapOnly = useDoubleTapOnly 70 | self.outputLanguage = outputLanguage 71 | self.selectedMicrophoneID = selectedMicrophoneID 72 | self.saveTranscriptionHistory = saveTranscriptionHistory 73 | self.maxHistoryEntries = maxHistoryEntries 74 | } 75 | 76 | // Custom decoder that handles missing fields 77 | init(from decoder: Decoder) throws { 78 | let container = try decoder.container(keyedBy: CodingKeys.self) 79 | 80 | // Decode each property, using decodeIfPresent with default fallbacks 81 | soundEffectsEnabled = 82 | try container.decodeIfPresent(Bool.self, forKey: .soundEffectsEnabled) ?? true 83 | hotkey = 84 | try container.decodeIfPresent(HotKey.self, forKey: .hotkey) 85 | ?? .init(key: nil, modifiers: [.option]) 86 | openOnLogin = try container.decodeIfPresent(Bool.self, forKey: .openOnLogin) ?? false 87 | showDockIcon = try container.decodeIfPresent(Bool.self, forKey: .showDockIcon) ?? true 88 | selectedModel = 89 | try container.decodeIfPresent(String.self, forKey: .selectedModel) 90 | ?? "openai_whisper-large-v3-v20240930" 91 | useClipboardPaste = try container.decodeIfPresent(Bool.self, forKey: .useClipboardPaste) ?? true 92 | preventSystemSleep = 93 | try container.decodeIfPresent(Bool.self, forKey: .preventSystemSleep) ?? true 94 | pauseMediaOnRecord = 95 | try container.decodeIfPresent(Bool.self, forKey: .pauseMediaOnRecord) ?? true 96 | minimumKeyTime = 97 | try container.decodeIfPresent(Double.self, forKey: .minimumKeyTime) ?? 0.2 98 | copyToClipboard = 99 | try container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? false 100 | useDoubleTapOnly = 101 | try container.decodeIfPresent(Bool.self, forKey: .useDoubleTapOnly) ?? false 102 | outputLanguage = try container.decodeIfPresent(String.self, forKey: .outputLanguage) 103 | selectedMicrophoneID = try container.decodeIfPresent(String.self, forKey: .selectedMicrophoneID) 104 | saveTranscriptionHistory = 105 | try container.decodeIfPresent(Bool.self, forKey: .saveTranscriptionHistory) ?? true 106 | maxHistoryEntries = try container.decodeIfPresent(Int.self, forKey: .maxHistoryEntries) 107 | } 108 | } 109 | 110 | extension SharedReaderKey 111 | where Self == FileStorageKey.Default 112 | { 113 | static var hexSettings: Self { 114 | Self[ 115 | .fileStorage(URL.documentsDirectory.appending(component: "hex_settings.json")), 116 | default: .init() 117 | ] 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Hex/Models/HotKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifier.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/26/25. 6 | // 7 | import Cocoa 8 | import Sauce 9 | 10 | public enum Modifier: Identifiable, Codable, Equatable, Hashable, Comparable { 11 | case command 12 | case option 13 | case shift 14 | case control 15 | case fn 16 | 17 | public var id: Self { self } 18 | 19 | public var stringValue: String { 20 | switch self { 21 | case .option: 22 | return "⌥" 23 | case .shift: 24 | return "⇧" 25 | case .command: 26 | return "⌘" 27 | case .control: 28 | return "⌃" 29 | case .fn: 30 | return "fn" 31 | } 32 | } 33 | } 34 | 35 | public struct Modifiers: Codable, Equatable, ExpressibleByArrayLiteral { 36 | var modifiers: Set 37 | 38 | var sorted: [Modifier] { 39 | // If this is a hyperkey combination (all four modifiers), 40 | // return an empty array as we'll display a special symbol 41 | if isHyperkey { 42 | return [] 43 | } 44 | return modifiers.sorted() 45 | } 46 | 47 | public var isHyperkey: Bool { 48 | return modifiers.contains(.command) && 49 | modifiers.contains(.option) && 50 | modifiers.contains(.shift) && 51 | modifiers.contains(.control) 52 | } 53 | 54 | public var isEmpty: Bool { 55 | modifiers.isEmpty 56 | } 57 | 58 | public init(modifiers: Set) { 59 | self.modifiers = modifiers 60 | } 61 | 62 | public init(arrayLiteral elements: Modifier...) { 63 | modifiers = Set(elements) 64 | } 65 | 66 | public func contains(_ modifier: Modifier) -> Bool { 67 | modifiers.contains(modifier) 68 | } 69 | 70 | public func isSubset(of other: Modifiers) -> Bool { 71 | modifiers.isSubset(of: other.modifiers) 72 | } 73 | 74 | public func isDisjoint(with other: Modifiers) -> Bool { 75 | modifiers.isDisjoint(with: other.modifiers) 76 | } 77 | 78 | public func union(_ other: Modifiers) -> Modifiers { 79 | Modifiers(modifiers: modifiers.union(other.modifiers)) 80 | } 81 | 82 | public func intersection(_ other: Modifiers) -> Modifiers { 83 | Modifiers(modifiers: modifiers.intersection(other.modifiers)) 84 | } 85 | 86 | public static func from(cocoa: NSEvent.ModifierFlags) -> Self { 87 | var modifiers: Set = [] 88 | if cocoa.contains(.option) { 89 | modifiers.insert(.option) 90 | } 91 | if cocoa.contains(.shift) { 92 | modifiers.insert(.shift) 93 | } 94 | if cocoa.contains(.command) { 95 | modifiers.insert(.command) 96 | } 97 | if cocoa.contains(.control) { 98 | modifiers.insert(.control) 99 | } 100 | if cocoa.contains(.function) { 101 | modifiers.insert(.fn) 102 | } 103 | return .init(modifiers: modifiers) 104 | } 105 | 106 | public static func from(carbonFlags: CGEventFlags) -> Modifiers { 107 | var modifiers: Set = [] 108 | if carbonFlags.contains(.maskShift) { modifiers.insert(.shift) } 109 | if carbonFlags.contains(.maskControl) { modifiers.insert(.control) } 110 | if carbonFlags.contains(.maskAlternate) { modifiers.insert(.option) } 111 | if carbonFlags.contains(.maskCommand) { modifiers.insert(.command) } 112 | if carbonFlags.contains(.maskSecondaryFn) { modifiers.insert(.fn) } 113 | return .init(modifiers: modifiers) 114 | } 115 | } 116 | 117 | public struct HotKey: Codable, Equatable { 118 | public var key: Key? 119 | public var modifiers: Modifiers 120 | } 121 | 122 | extension Key { 123 | var toString: String { 124 | switch self { 125 | case .escape: 126 | return "⎋" 127 | case .zero: 128 | return "0" 129 | case .one: 130 | return "1" 131 | case .two: 132 | return "2" 133 | case .three: 134 | return "3" 135 | case .four: 136 | return "4" 137 | case .five: 138 | return "5" 139 | case .six: 140 | return "6" 141 | case .seven: 142 | return "7" 143 | case .eight: 144 | return "8" 145 | case .nine: 146 | return "9" 147 | case .period: 148 | return "." 149 | case .comma: 150 | return "," 151 | case .slash: 152 | return "/" 153 | case .quote: 154 | return "\"" 155 | case .backslash: 156 | return "\\" 157 | default: 158 | return rawValue.uppercased() 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Hex/Models/Language.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | 4 | // Represents a single language option 5 | struct Language: Codable, Identifiable, Hashable, Equatable { 6 | let code: String? // nil is used for the "Auto" option 7 | let name: String 8 | 9 | var id: String { code ?? "auto" } 10 | } 11 | 12 | // Container for the language data loaded from JSON 13 | struct LanguageList: Codable { 14 | let languages: [Language] 15 | } 16 | -------------------------------------------------------------------------------- /Hex/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Hex/Resources/Audio/cancel.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Resources/Audio/cancel.mp3 -------------------------------------------------------------------------------- /Hex/Resources/Audio/pasteTranscript.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Resources/Audio/pasteTranscript.mp3 -------------------------------------------------------------------------------- /Hex/Resources/Audio/startRecording.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Resources/Audio/startRecording.mp3 -------------------------------------------------------------------------------- /Hex/Resources/Audio/stopRecording.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitlangton/Hex/a72dd9691548bdd29d6a4afec0f06c54f9fbb99c/Hex/Resources/Audio/stopRecording.mp3 -------------------------------------------------------------------------------- /Hex/Resources/Data/languages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Auto" 4 | }, 5 | { 6 | "code": "af", 7 | "name": "Afrikaans" 8 | }, 9 | { 10 | "code": "ar", 11 | "name": "Arabic" 12 | }, 13 | { 14 | "code": "hy", 15 | "name": "Armenian" 16 | }, 17 | { 18 | "code": "az", 19 | "name": "Azerbaijani" 20 | }, 21 | { 22 | "code": "be", 23 | "name": "Belarusian" 24 | }, 25 | { 26 | "code": "bs", 27 | "name": "Bosnian" 28 | }, 29 | { 30 | "code": "bg", 31 | "name": "Bulgarian" 32 | }, 33 | { 34 | "code": "ca", 35 | "name": "Catalan" 36 | }, 37 | { 38 | "code": "zh", 39 | "name": "Chinese" 40 | }, 41 | { 42 | "code": "hr", 43 | "name": "Croatian" 44 | }, 45 | { 46 | "code": "cs", 47 | "name": "Czech" 48 | }, 49 | { 50 | "code": "da", 51 | "name": "Danish" 52 | }, 53 | { 54 | "code": "nl", 55 | "name": "Dutch" 56 | }, 57 | { 58 | "code": "en", 59 | "name": "English" 60 | }, 61 | { 62 | "code": "et", 63 | "name": "Estonian" 64 | }, 65 | { 66 | "code": "fi", 67 | "name": "Finnish" 68 | }, 69 | { 70 | "code": "fr", 71 | "name": "French" 72 | }, 73 | { 74 | "code": "gl", 75 | "name": "Galician" 76 | }, 77 | { 78 | "code": "de", 79 | "name": "German" 80 | }, 81 | { 82 | "code": "el", 83 | "name": "Greek" 84 | }, 85 | { 86 | "code": "he", 87 | "name": "Hebrew" 88 | }, 89 | { 90 | "code": "hi", 91 | "name": "Hindi" 92 | }, 93 | { 94 | "code": "hu", 95 | "name": "Hungarian" 96 | }, 97 | { 98 | "code": "is", 99 | "name": "Icelandic" 100 | }, 101 | { 102 | "code": "id", 103 | "name": "Indonesian" 104 | }, 105 | { 106 | "code": "it", 107 | "name": "Italian" 108 | }, 109 | { 110 | "code": "ja", 111 | "name": "Japanese" 112 | }, 113 | { 114 | "code": "kn", 115 | "name": "Kannada" 116 | }, 117 | { 118 | "code": "kk", 119 | "name": "Kazakh" 120 | }, 121 | { 122 | "code": "ko", 123 | "name": "Korean" 124 | }, 125 | { 126 | "code": "lv", 127 | "name": "Latvian" 128 | }, 129 | { 130 | "code": "lt", 131 | "name": "Lithuanian" 132 | }, 133 | { 134 | "code": "mk", 135 | "name": "Macedonian" 136 | }, 137 | { 138 | "code": "ms", 139 | "name": "Malay" 140 | }, 141 | { 142 | "code": "mr", 143 | "name": "Marathi" 144 | }, 145 | { 146 | "code": "mi", 147 | "name": "Maori" 148 | }, 149 | { 150 | "code": "ne", 151 | "name": "Nepali" 152 | }, 153 | { 154 | "code": "no", 155 | "name": "Norwegian" 156 | }, 157 | { 158 | "code": "fa", 159 | "name": "Persian" 160 | }, 161 | { 162 | "code": "pl", 163 | "name": "Polish" 164 | }, 165 | { 166 | "code": "pt", 167 | "name": "Portuguese" 168 | }, 169 | { 170 | "code": "ro", 171 | "name": "Romanian" 172 | }, 173 | { 174 | "code": "ru", 175 | "name": "Russian" 176 | }, 177 | { 178 | "code": "sr", 179 | "name": "Serbian" 180 | }, 181 | { 182 | "code": "sk", 183 | "name": "Slovak" 184 | }, 185 | { 186 | "code": "sl", 187 | "name": "Slovenian" 188 | }, 189 | { 190 | "code": "es", 191 | "name": "Spanish" 192 | }, 193 | { 194 | "code": "sw", 195 | "name": "Swahili" 196 | }, 197 | { 198 | "code": "sv", 199 | "name": "Swedish" 200 | }, 201 | { 202 | "code": "tl", 203 | "name": "Tagalog" 204 | }, 205 | { 206 | "code": "ta", 207 | "name": "Tamil" 208 | }, 209 | { 210 | "code": "th", 211 | "name": "Thai" 212 | }, 213 | { 214 | "code": "tr", 215 | "name": "Turkish" 216 | }, 217 | { 218 | "code": "uk", 219 | "name": "Ukrainian" 220 | }, 221 | { 222 | "code": "ur", 223 | "name": "Urdu" 224 | }, 225 | { 226 | "code": "vi", 227 | "name": "Vietnamese" 228 | }, 229 | { 230 | "code": "cy", 231 | "name": "Welsh" 232 | } 233 | ] -------------------------------------------------------------------------------- /Hex/Resources/Data/models.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "displayName": "Small", 4 | "internalName": "openai_whisper-tiny", 5 | "size": "Small", 6 | "accuracyStars": 2, 7 | "speedStars": 4, 8 | "storageSize": "100MB" 9 | }, 10 | { 11 | "displayName": "Medium", 12 | "internalName": "openai_whisper-base", 13 | "size": "Medium", 14 | "accuracyStars": 3, 15 | "speedStars": 3, 16 | "storageSize": "500MB" 17 | }, 18 | { 19 | "displayName": "Large", 20 | "internalName": "openai_whisper-large-v3-v20240930", 21 | "size": "Large", 22 | "accuracyStars": 4, 23 | "speedStars": 2, 24 | "storageSize": "1.5GB" 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /Hex/Resources/changelog.md: -------------------------------------------------------------------------------- 1 | ### 1.4 2 | - Bump version for stable release 3 | 4 | ### 0.1.33 5 | - Add copy to clipboard option 6 | - Add support for complete keyboard shortcuts 7 | - Fix issue with Hex showing in Mission Control and Cmd+Tab 8 | - Add indication for model prewarming 9 | - Improve paste behavior when text input fails 10 | - Rework audio pausing logic to make it more reliable 11 | 12 | ### 0.1.26 13 | - Add changelog 14 | - Add option to set minimum record time 15 | -------------------------------------------------------------------------------- /Hex/Views/InvisibleWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InvisibleWindow.swift 3 | // Hex 4 | // 5 | // Created by Kit Langton on 1/24/25. 6 | // 7 | 8 | import AppKit 9 | import SwiftUI 10 | 11 | /// This allows us to render SwiftUI views anywhere on the screen, without dealing with the awkward 12 | /// rendering issues that come with normal MacOS windows. Essentially, we create one giant invisible 13 | /// window that covers the entire screen, and render our SwiftUI views into it. 14 | /// 15 | /// I'm pretty sure this is what CleanShot X and other apps do to render their floating widgets. 16 | /// But if there's a better way to do this, I'd love to know! 17 | class InvisibleWindow: NSPanel { 18 | override var canBecomeKey: Bool { true } 19 | override var canBecomeMain: Bool { true } 20 | 21 | init() { 22 | let screen = NSScreen.main ?? NSScreen.screens[0] 23 | let styleMask: NSWindow.StyleMask = [.fullSizeContentView, .borderless, .utilityWindow, .nonactivatingPanel] 24 | 25 | super.init(contentRect: screen.frame, 26 | styleMask: styleMask, 27 | backing: .buffered, 28 | defer: false) 29 | 30 | level = .modalPanel 31 | backgroundColor = .clear 32 | isOpaque = false 33 | hasShadow = false 34 | hidesOnDeactivate = false // Prevent hiding when app loses focus 35 | canHide = false 36 | collectionBehavior = [.fullScreenAuxiliary, .canJoinAllSpaces, .stationary, .ignoresCycle] 37 | 38 | // Set initial frame 39 | updateToScreenWithMouse() 40 | 41 | // Start observing screen changes 42 | NotificationCenter.default.addObserver( 43 | self, 44 | selector: #selector(screenDidChange), 45 | name: NSWindow.didChangeScreenNotification, 46 | object: nil 47 | ) 48 | 49 | // Also observe screen parameters for resolution changes 50 | NotificationCenter.default.addObserver( 51 | self, 52 | selector: #selector(screenDidChange), 53 | name: NSApplication.didChangeScreenParametersNotification, 54 | object: nil 55 | ) 56 | } 57 | 58 | deinit { 59 | NotificationCenter.default.removeObserver(self) 60 | } 61 | 62 | private func updateToScreenWithMouse() { 63 | let mouseLocation = NSEvent.mouseLocation 64 | guard let screenWithMouse = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return } 65 | setFrame(screenWithMouse.frame, display: true) 66 | } 67 | 68 | @objc private func screenDidChange(_: Notification) { 69 | updateToScreenWithMouse() 70 | } 71 | } 72 | 73 | extension InvisibleWindow: NSWindowDelegate { 74 | static func fromView(_ view: V) -> InvisibleWindow { 75 | let window = InvisibleWindow() 76 | window.contentView = NSHostingView(rootView: view) 77 | window.delegate = window 78 | return window 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "" : { 5 | "shouldTranslate" : false 6 | }, 7 | "•" : { 8 | "shouldTranslate" : false 9 | }, 10 | "50" : { 11 | 12 | }, 13 | "100" : { 14 | 15 | }, 16 | "200" : { 17 | 18 | }, 19 | "500" : { 20 | 21 | }, 22 | "1000" : { 23 | 24 | }, 25 | "About" : { 26 | "comment" : "Settings -> About Section headline", 27 | "localizations" : { 28 | "de" : { 29 | "stringUnit" : { 30 | "state" : "translated", 31 | "value" : "Über" 32 | } 33 | } 34 | } 35 | }, 36 | "Accessibility" : { 37 | "comment" : "Settings -> Accessibility Section headline", 38 | "localizations" : { 39 | "de" : { 40 | "stringUnit" : { 41 | "state" : "translated", 42 | "value" : "Bedienungshilfen" 43 | } 44 | } 45 | } 46 | }, 47 | "Accuracy" : { 48 | 49 | }, 50 | "Are you sure you want to delete all transcripts? This action cannot be undone." : { 51 | "comment" : "Delete transcript history confirm", 52 | "localizations" : { 53 | "de" : { 54 | "stringUnit" : { 55 | "state" : "translated", 56 | "value" : "Bist du sicher, dass du alle Transkriptionen löschen möchtest? Das kann nicht gängig gemacht werden." 57 | } 58 | } 59 | } 60 | }, 61 | "Become a Sponsor" : { 62 | 63 | }, 64 | "Cancel" : { 65 | "comment" : "Cancel deleting All Transcripts", 66 | "localizations" : { 67 | "de" : { 68 | "stringUnit" : { 69 | "state" : "translated", 70 | "value" : "Abbrechen" 71 | } 72 | } 73 | } 74 | }, 75 | "Changelog" : { 76 | "localizations" : { 77 | "de" : { 78 | "stringUnit" : { 79 | "state" : "translated", 80 | "value" : "Änderungen" 81 | } 82 | } 83 | } 84 | }, 85 | "Changelog could not be loaded." : { 86 | "localizations" : { 87 | "de" : { 88 | "stringUnit" : { 89 | "state" : "translated", 90 | "value" : "Änderungen konnten nicht geladen werden." 91 | } 92 | } 93 | } 94 | }, 95 | "Check for Updates" : { 96 | "comment" : "Check for updates button in About section of Settings", 97 | "localizations" : { 98 | "de" : { 99 | "stringUnit" : { 100 | "state" : "translated", 101 | "value" : "Nach Updates suchen" 102 | } 103 | } 104 | } 105 | }, 106 | "Check for Updates…" : { 107 | "comment" : "Check for updates button in top menu", 108 | "localizations" : { 109 | "de" : { 110 | "stringUnit" : { 111 | "state" : "translated", 112 | "value" : "Suche nach Updates…" 113 | } 114 | } 115 | } 116 | }, 117 | "Close" : { 118 | "localizations" : { 119 | "de" : { 120 | "stringUnit" : { 121 | "state" : "translated", 122 | "value" : "Schließen" 123 | } 124 | } 125 | } 126 | }, 127 | "Copied" : { 128 | "comment" : "Transcription from history copied.", 129 | "localizations" : { 130 | "de" : { 131 | "stringUnit" : { 132 | "state" : "translated", 133 | "value" : "Kopiert" 134 | } 135 | } 136 | } 137 | }, 138 | "Copy to clipboard" : { 139 | "comment" : "copy transcription from history.", 140 | "localizations" : { 141 | "de" : { 142 | "stringUnit" : { 143 | "state" : "translated", 144 | "value" : "In die Zwischenablage kopieren" 145 | } 146 | } 147 | } 148 | }, 149 | "Copy transcription text to clipboard in addition to pasting it" : { 150 | 151 | }, 152 | "Delete" : { 153 | 154 | }, 155 | "Delete All" : { 156 | "comment" : "Delete all transcriptions from history.", 157 | "localizations" : { 158 | "de" : { 159 | "stringUnit" : { 160 | "state" : "translated", 161 | "value" : "Alle löschen" 162 | } 163 | } 164 | } 165 | }, 166 | "Delete All Transcripts" : { 167 | "comment" : "\"Delete all transcriptions from history\" pop up headline", 168 | "localizations" : { 169 | "de" : { 170 | "stringUnit" : { 171 | "state" : "translated", 172 | "value" : "Alle Transkriptionen löschen" 173 | } 174 | } 175 | } 176 | }, 177 | "Delete Selected Model" : { 178 | "comment" : "Delete the selected model in settings.", 179 | "extractionState" : "stale", 180 | "localizations" : { 181 | "de" : { 182 | "stringUnit" : { 183 | "state" : "translated", 184 | "value" : "Ausgewähltes Modell löschen" 185 | } 186 | } 187 | } 188 | }, 189 | "Delete transcript" : { 190 | "comment" : "Delete individual transcript button. Help text.", 191 | "localizations" : { 192 | "de" : { 193 | "stringUnit" : { 194 | "state" : "translated", 195 | "value" : "Transkription löschen" 196 | } 197 | } 198 | } 199 | }, 200 | "Download" : { 201 | 202 | }, 203 | "Download Error: %@" : { 204 | "comment" : "Model download error.", 205 | "localizations" : { 206 | "de" : { 207 | "stringUnit" : { 208 | "state" : "translated", 209 | "value" : "Downloadfehler: %@" 210 | } 211 | } 212 | } 213 | }, 214 | "Download Selected Model" : { 215 | "comment" : "In Transcription Model section in settings.", 216 | "extractionState" : "stale", 217 | "localizations" : { 218 | "de" : { 219 | "stringUnit" : { 220 | "state" : "translated", 221 | "value" : "Ausgewähltes Modell herunterladen" 222 | } 223 | } 224 | } 225 | }, 226 | "Downloading %@..." : { 227 | "comment" : "In Transcription Model section in settings.", 228 | "extractionState" : "stale", 229 | "localizations" : { 230 | "de" : { 231 | "stringUnit" : { 232 | "state" : "translated", 233 | "value" : "Lade %@ herunter" 234 | } 235 | } 236 | } 237 | }, 238 | "Downloading model..." : { 239 | 240 | }, 241 | "Enable in Settings" : { 242 | 243 | }, 244 | "Ensure Hex can access your microphone and system accessibility features." : { 245 | "comment" : "Footer for permissions section in settings", 246 | "localizations" : { 247 | "de" : { 248 | "stringUnit" : { 249 | "state" : "translated", 250 | "value" : "Sicherstellen, dass Hex dein Mikrofon und Systemweite Bedienungshilfen benutzen kann." 251 | } 252 | } 253 | } 254 | }, 255 | "Enter a key combination" : { 256 | "comment" : "Replacement text to be shown when hotkey selection is active.", 257 | "localizations" : { 258 | "de" : { 259 | "stringUnit" : { 260 | "state" : "translated", 261 | "value" : "Eingabe einer Tastenkombination" 262 | } 263 | } 264 | } 265 | }, 266 | "General" : { 267 | "comment" : "General section in Settings Header.", 268 | "localizations" : { 269 | "de" : { 270 | "stringUnit" : { 271 | "state" : "translated", 272 | "value" : "Allgemein" 273 | } 274 | } 275 | } 276 | }, 277 | "Granted" : { 278 | "comment" : "Label title for granted permissions.", 279 | "localizations" : { 280 | "de" : { 281 | "stringUnit" : { 282 | "state" : "translated", 283 | "value" : "Zugelassen" 284 | } 285 | } 286 | } 287 | }, 288 | "Hex is open source" : { 289 | "comment" : "In about section in settings.", 290 | "localizations" : { 291 | "de" : { 292 | "stringUnit" : { 293 | "state" : "translated", 294 | "value" : "Hex ist Open Source" 295 | } 296 | } 297 | } 298 | }, 299 | "History" : { 300 | "comment" : "History View Title", 301 | "localizations" : { 302 | "de" : { 303 | "stringUnit" : { 304 | "state" : "translated", 305 | "value" : "Transkriptionsaktivitäten" 306 | } 307 | } 308 | } 309 | }, 310 | "History Disabled" : { 311 | 312 | }, 313 | "Hot Key" : { 314 | "comment" : "hotkey section in settings.", 315 | "localizations" : { 316 | "de" : { 317 | "stringUnit" : { 318 | "state" : "translated", 319 | "value" : "Tassenkombination" 320 | } 321 | } 322 | } 323 | }, 324 | "Ignore below %.1fs" : { 325 | "localizations" : { 326 | "de" : { 327 | "stringUnit" : { 328 | "state" : "translated", 329 | "value" : "Unter %0.1fs ignorieren" 330 | } 331 | } 332 | } 333 | }, 334 | "Input Device" : { 335 | 336 | }, 337 | "Maximum History Entries" : { 338 | 339 | }, 340 | "Microphone" : { 341 | "comment" : "Microphone permission.", 342 | "extractionState" : "stale", 343 | "localizations" : { 344 | "de" : { 345 | "stringUnit" : { 346 | "state" : "translated", 347 | "value" : "Mikrofon" 348 | } 349 | } 350 | } 351 | }, 352 | "Microphone Selection" : { 353 | 354 | }, 355 | "Microphone!" : { 356 | 357 | }, 358 | "Model" : { 359 | 360 | }, 361 | "Model prewarming..." : { 362 | 363 | }, 364 | "No models found." : { 365 | "comment" : "Replacement text in transcription model section when no available models are found", 366 | "extractionState" : "stale", 367 | "localizations" : { 368 | "de" : { 369 | "stringUnit" : { 370 | "state" : "translated", 371 | "value" : "Keine Modelle gefunden." 372 | } 373 | } 374 | } 375 | }, 376 | "No Transcriptions" : { 377 | "comment" : "Empty history headline.", 378 | "localizations" : { 379 | "de" : { 380 | "stringUnit" : { 381 | "state" : "translated", 382 | "value" : "Keine Transkriptionen" 383 | } 384 | } 385 | } 386 | }, 387 | "Oldest entries will be automatically deleted when limit is reached" : { 388 | 389 | }, 390 | "Open on Login" : { 391 | "comment" : "Label for general setting to open app on login.", 392 | "localizations" : { 393 | "de" : { 394 | "stringUnit" : { 395 | "state" : "translated", 396 | "value" : "Bei Anmeldung öffnen" 397 | } 398 | } 399 | } 400 | }, 401 | "Output Language" : { 402 | "comment" : "transcription model setting for which language to use for text output.", 403 | "localizations" : { 404 | "de" : { 405 | "stringUnit" : { 406 | "state" : "translated", 407 | "value" : "Ausgabesprache" 408 | } 409 | } 410 | } 411 | }, 412 | "Override the system default microphone with a specific input device. This setting will persist across sessions." : { 413 | 414 | }, 415 | "Pause Media while Recording" : { 416 | "comment" : "Label for general setting whether to pause media while recording.", 417 | "localizations" : { 418 | "de" : { 419 | "stringUnit" : { 420 | "state" : "translated", 421 | "value" : "Medien während der Aufnahme pausieren" 422 | } 423 | } 424 | } 425 | }, 426 | "Permissions" : { 427 | "comment" : "Header for permission section in settings.", 428 | "localizations" : { 429 | "de" : { 430 | "stringUnit" : { 431 | "state" : "translated", 432 | "value" : "Berechtigungen" 433 | } 434 | } 435 | } 436 | }, 437 | "Play audio" : { 438 | "comment" : "Help text for play button in history view.", 439 | "localizations" : { 440 | "de" : { 441 | "stringUnit" : { 442 | "state" : "translated", 443 | "value" : "Audio abspielen" 444 | } 445 | } 446 | } 447 | }, 448 | "Prevent System Sleep while Recording" : { 449 | "comment" : "Label for general setting whether to prevent system sleep while recording.", 450 | "localizations" : { 451 | "de" : { 452 | "stringUnit" : { 453 | "state" : "translated", 454 | "value" : "Systemschlaf während der Aufnahme verhindern" 455 | } 456 | } 457 | } 458 | }, 459 | "Quit" : { 460 | 461 | }, 462 | "Recommended for custom hotkeys to avoid interfering with normal usage" : { 463 | 464 | }, 465 | "Refresh available input devices" : { 466 | 467 | }, 468 | "Request Permission" : { 469 | "comment" : "Button texts for requesting permissions in settings.", 470 | "localizations" : { 471 | "de" : { 472 | "stringUnit" : { 473 | "state" : "translated", 474 | "value" : "Erlaubnis anfordern" 475 | } 476 | } 477 | } 478 | }, 479 | "Save Transcription History" : { 480 | 481 | }, 482 | "Save transcriptions and audio recordings for later access" : { 483 | 484 | }, 485 | "Selected device not connected. System default will be used." : { 486 | 487 | }, 488 | "Selected Model" : { 489 | "comment" : "Label for selecting which model is active", 490 | "localizations" : { 491 | "de" : { 492 | "stringUnit" : { 493 | "state" : "translated", 494 | "value" : "Modell auswählen" 495 | } 496 | } 497 | } 498 | }, 499 | "Selected: %@" : { 500 | 501 | }, 502 | "Settings" : { 503 | "comment" : "title for settings view.", 504 | "localizations" : { 505 | "de" : { 506 | "stringUnit" : { 507 | "state" : "translated", 508 | "value" : "Einstellungen" 509 | } 510 | } 511 | } 512 | }, 513 | "Settings..." : { 514 | "comment" : "button in top bar for opening settings", 515 | "localizations" : { 516 | "de" : { 517 | "stringUnit" : { 518 | "state" : "translated", 519 | "value" : "Einstellungen…" 520 | } 521 | } 522 | } 523 | }, 524 | "Show All Models" : { 525 | 526 | }, 527 | "Show Changelog" : { 528 | "localizations" : { 529 | "de" : { 530 | "stringUnit" : { 531 | "state" : "translated", 532 | "value" : "Änderungen anzeigen" 533 | } 534 | } 535 | } 536 | }, 537 | "Show Dock Icon" : { 538 | "comment" : "label for general setting whether to show the dock icon or not.", 539 | "localizations" : { 540 | "de" : { 541 | "stringUnit" : { 542 | "state" : "translated", 543 | "value" : "Dock-Symbol anzeigen" 544 | } 545 | } 546 | } 547 | }, 548 | "Show Models Folder" : { 549 | 550 | }, 551 | "Show Recommended" : { 552 | 553 | }, 554 | "Showing all models" : { 555 | 556 | }, 557 | "Showing recommended models" : { 558 | 559 | }, 560 | "Size" : { 561 | 562 | }, 563 | "Sound" : { 564 | "comment" : "sound section in general settings.", 565 | "localizations" : { 566 | "de" : { 567 | "stringUnit" : { 568 | "state" : "translated", 569 | "value" : "Ton" 570 | } 571 | } 572 | } 573 | }, 574 | "Sound Effects" : { 575 | "comment" : "Label for toggle for sound effects in sound section in settings.", 576 | "localizations" : { 577 | "de" : { 578 | "stringUnit" : { 579 | "state" : "translated", 580 | "value" : "Ton-Effekte" 581 | } 582 | } 583 | } 584 | }, 585 | "Speed" : { 586 | 587 | }, 588 | "Stop playback" : { 589 | "comment" : "Help text for play button in history view.", 590 | "localizations" : { 591 | "de" : { 592 | "stringUnit" : { 593 | "state" : "translated", 594 | "value" : "Wiedergabe anhalten." 595 | } 596 | } 597 | } 598 | }, 599 | "Support the developer" : { 600 | 601 | }, 602 | "System Default" : { 603 | 604 | }, 605 | "Transcription history is currently disabled." : { 606 | 607 | }, 608 | "Transcription Model" : { 609 | "comment" : "Label for Transcription Model Section", 610 | "localizations" : { 611 | "de" : { 612 | "stringUnit" : { 613 | "state" : "translated", 614 | "value" : "Transkriptionsmodell" 615 | } 616 | } 617 | } 618 | }, 619 | "Unlimited" : { 620 | 621 | }, 622 | "Use clipboard to insert" : { 623 | "comment" : "Label for toggle in general section.", 624 | "localizations" : { 625 | "de" : { 626 | "stringUnit" : { 627 | "state" : "translated", 628 | "value" : "Zwischenablage zum Einfügen verwenden" 629 | } 630 | } 631 | } 632 | }, 633 | "Use clipboard to insert text. Fast but may not restore all clipboard content.\nTurn off to use simulated keypresses. Slower, but doesn't need to restore clipboard" : { 634 | "comment" : "More info on \"use clipboard to insert\" toggle in general section.", 635 | "localizations" : { 636 | "de" : { 637 | "stringUnit" : { 638 | "state" : "translated", 639 | "value" : "Zwischenablage zum Einfügen von Text benutzen. Schnell, aber möglicherweise wird nicht der gesamte Inhalt der Zwischenablage wiederhergestellt.\nAusschalten, um simulierte Tasteneingaben zu verwenden. Langsamer, aber muss die Zwischenablage nicht wiederherstellen." 640 | } 641 | } 642 | } 643 | }, 644 | "Use double-tap only" : { 645 | 646 | }, 647 | "Version" : { 648 | "comment" : "Label for version in About section.", 649 | "localizations" : { 650 | "de" : { 651 | "stringUnit" : { 652 | "state" : "translated", 653 | "value" : "Version" 654 | } 655 | } 656 | } 657 | }, 658 | "Visit our GitHub" : { 659 | "comment" : "button text to link to github", 660 | "localizations" : { 661 | "de" : { 662 | "stringUnit" : { 663 | "state" : "translated", 664 | "value" : "Besuche unser GitHub" 665 | } 666 | } 667 | } 668 | }, 669 | "When disabled, transcriptions will not be saved and audio files will be deleted immediately after transcription." : { 670 | 671 | }, 672 | "You're using a full keyboard shortcut. Double-tap is recommended." : { 673 | 674 | }, 675 | "Your transcription history will appear here." : { 676 | "comment" : "Empty history subtitle.", 677 | "localizations" : { 678 | "de" : { 679 | "stringUnit" : { 680 | "state" : "translated", 681 | "value" : "Deine Transkriptionsaktivitäten werden hier angezeigt werden" 682 | } 683 | } 684 | } 685 | } 686 | }, 687 | "version" : "1.0" 688 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hex — Voice → Text 2 | 3 | Press-and-hold a hotkey to transcribe your voice and paste the result wherever you're typing. 4 | 5 | **[Download Hex for macOS](https://hex-updates.s3.us-east-1.amazonaws.com/hex-latest.dmg)** 6 | > **Note:** Hex is currently only available for **Apple Silicon** Macs. 7 | 8 | I've opened-sourced the project in the hopes that others will find it useful! We rely on the awesome [WhisperKit](https://github.com/argmaxinc/WhisperKit) for transcription, and the incredible [Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) for structuring the app. Please open issues with any questions or feedback! ❤️ 9 | 10 | Join our [Discord community](https://discord.gg/5UzVCqWmav) for support, discussions, and updates! 11 | 12 | ## Instructions 13 | 14 | Once you open Hex, you'll need to grant it microphone and accessibility permissions—so it can record your voice and paste the transcribed text into any application, respectively. 15 | 16 | Once you've configured a global hotkey, there are **two recording modes**: 17 | 18 | 1. **Press-and-hold** the hotkey to begin recording, say whatever you want, and then release the hotkey to start the transcription process. 19 | 2. **Double-tap** the hotkey to *lock recording*, say whatever you want, and then **tap** the hotkey once more to start the transcription process. 20 | 21 | > ⚠️ Note: The first time you run Hex, it will download and compile the Whisper model for your machine. During this process, you may notice high CPU usage from a background process called ANECompilerService. This is macOS optimizing the model for the Apple Neural Engine (ANE), and it's a normal one-time setup step. 22 | > 23 | > Depending on your CPU and the size of the model, this may take anywhere from a few seconds to a few minutes. 24 | ## Project Structure 25 | 26 | Hex is organized into several directories, each serving a specific purpose: 27 | 28 | - **`App/`** 29 | - Contains the main application entry point (`HexApp.swift`) and the app delegate (`HexAppDelegate.swift`), which manage the app's lifecycle and initial setup. 30 | 31 | - **`Clients/`** 32 | - `PasteboardClient.swift` 33 | - Manages pasteboard operations for copying transcriptions. 34 | - `SoundEffect.swift` 35 | - Controls sound effects for user feedback. 36 | - `RecordingClient.swift` 37 | - Manages audio recording and microphone access. 38 | - `KeyEventMonitorClient.swift` 39 | - Monitors global key events for hotkey detection. 40 | - `TranscriptionClient.swift` 41 | - Interfaces with WhisperKit for transcription services. 42 | 43 | - **`Features/`** 44 | - `AppFeature.swift` 45 | - The root feature that composes transcription, settings, and history. 46 | - `TranscriptionFeature.swift` 47 | - Manages the core transcription logic and recording flow. 48 | - `SettingsFeature.swift` 49 | - Handles app settings, including hotkey configuration and permissions. 50 | - `HistoryFeature.swift` 51 | - Manages the transcription history view. 52 | 53 | - **`Models/`** 54 | - `HexSettings.swift` 55 | - Stores user preferences like hotkey settings and sound preferences. 56 | - `HotKey.swift` 57 | - Represents the hotkey configuration. 58 | 59 | - **`Resources/`** 60 | - Contains the app's assets, including the app icon and sound effects. 61 | - `changelog.md` 62 | - A log of changes to the app. 63 | - `Data/languages.json` 64 | - A list of supported languages for transcription. 65 | - `Audio/` 66 | - Sound effects for user feedback. 67 | --------------------------------------------------------------------------------