├── .DS_Store ├── Assets.xcassets ├── Contents.json ├── AppIcon.appiconset │ ├── Gemini_Generated_Image_3wuq7c3wuq7c3wuq.png │ └── Contents.json └── AccentColor.colorset │ └── Contents.json ├── OTPExtractor.entitlements ├── ClipboardManager.swift ├── Constants.swift ├── PreferencesManager.swift ├── .gitignore ├── PreferencesWindow.swift ├── BUILD.md ├── TROUBLESHOOTING.md ├── README.md └── OTPExtractorApp.swift /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/error2/macos-otp-extractor/main/.DS_Store -------------------------------------------------------------------------------- /Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_3wuq7c3wuq7c3wuq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/error2/macos-otp-extractor/main/Assets.xcassets/AppIcon.appiconset/Gemini_Generated_Image_3wuq7c3wuq7c3wuq.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /OTPExtractor.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "filename" : "Gemini_Generated_Image_3wuq7c3wuq7c3wuq.png", 50 | "idiom" : "mac", 51 | "scale" : "2x", 52 | "size" : "512x512" 53 | } 54 | ], 55 | "info" : { 56 | "author" : "xcode", 57 | "version" : 1 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ClipboardManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardManager.swift 3 | // OTPExtractor 4 | // 5 | // Created by Error 6 | // 7 | 8 | import AppKit 9 | 10 | /// Manages clipboard operations with security features 11 | class ClipboardManager { 12 | static let shared = ClipboardManager() 13 | 14 | private var clearTimer: Timer? 15 | private var lastCopiedText: String? 16 | 17 | private init() {} 18 | 19 | /// Copies text to clipboard with optional auto-clear feature 20 | /// - Parameters: 21 | /// - text: The text to copy 22 | /// - autoClear: Whether to automatically clear the clipboard after a delay 23 | /// - delay: The delay before clearing (defaults to Constants.clipboardClearDelay) 24 | func copy(text: String, autoClear: Bool = true, delay: TimeInterval = Constants.clipboardClearDelay) { 25 | let pasteboard = NSPasteboard.general 26 | pasteboard.clearContents() 27 | pasteboard.setString(text, forType: .string) 28 | 29 | lastCopiedText = text 30 | 31 | // Cancel any existing timer 32 | clearTimer?.invalidate() 33 | 34 | // Set up auto-clear if enabled 35 | if autoClear && UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.autoClipboardClear) { 36 | let clearDelay = UserDefaults.standard.double(forKey: Constants.UserDefaultsKeys.clipboardClearDelay) 37 | let actualDelay = clearDelay > 0 ? clearDelay : delay 38 | 39 | clearTimer = Timer.scheduledTimer(withTimeInterval: actualDelay, repeats: false) { [weak self] _ in 40 | self?.clearIfUnchanged() 41 | } 42 | } 43 | } 44 | 45 | /// Clears the clipboard only if it still contains the last copied OTP 46 | private func clearIfUnchanged() { 47 | guard let lastText = lastCopiedText else { return } 48 | 49 | let pasteboard = NSPasteboard.general 50 | if let currentText = pasteboard.string(forType: .string), currentText == lastText { 51 | pasteboard.clearContents() 52 | } 53 | 54 | lastCopiedText = nil 55 | } 56 | 57 | /// Manually cancels the auto-clear timer 58 | func cancelAutoClear() { 59 | clearTimer?.invalidate() 60 | clearTimer = nil 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // OTPExtractor 4 | // 5 | // Created by Error 6 | // 7 | 8 | import Foundation 9 | 10 | /// Application-wide constants 11 | enum Constants { 12 | // MARK: - Time Constants 13 | 14 | /// Apple epoch offset (seconds between Unix epoch and Apple's reference date) 15 | static let appleEpochOffset: Double = 978307200.0 16 | 17 | /// Conversion factor from nanoseconds to seconds 18 | static let nanosecondsToSeconds: Double = 1_000_000_000.0 19 | 20 | /// Database polling interval in seconds 21 | static let pollingInterval: TimeInterval = 5.0 22 | 23 | /// Clipboard auto-clear delay in seconds 24 | static let clipboardClearDelay: TimeInterval = 60.0 25 | 26 | /// Animation duration for status icon feedback 27 | static let statusIconAnimationDuration: TimeInterval = 2.0 28 | 29 | // MARK: - UI Constants 30 | 31 | /// Maximum number of OTP codes to keep in history 32 | static let defaultMaxHistorySize: Int = 3 33 | 34 | /// App version string 35 | static let appVersion: String = "1.3.0" 36 | 37 | // MARK: - Database Constants 38 | 39 | /// Relative path to Messages database from home directory 40 | static let messagesDBPath: String = "Library/Messages/chat.db" 41 | 42 | // MARK: - OTP Pattern Constants 43 | 44 | /// Minimum OTP length (digits) 45 | static let minOTPLength: Int = 4 46 | 47 | /// Maximum OTP length (digits) 48 | static let maxOTPLength: Int = 9 49 | 50 | /// Minimum alphanumeric OTP length 51 | static let minAlphanumericOTPLength: Int = 6 52 | 53 | /// Maximum alphanumeric OTP length 54 | static let maxAlphanumericOTPLength: Int = 8 55 | 56 | // MARK: - User Defaults Keys 57 | 58 | enum UserDefaultsKeys { 59 | static let pollingInterval = "pollingInterval" 60 | static let maxHistorySize = "maxHistorySize" 61 | static let autoClipboardClear = "autoClipboardClear" 62 | static let clipboardClearDelay = "clipboardClearDelay" 63 | static let notificationsEnabled = "notificationsEnabled" 64 | static let soundEnabled = "soundEnabled" 65 | } 66 | 67 | // MARK: - Notification Names 68 | 69 | enum Notifications { 70 | static let otpDetected = NSNotification.Name("OTPDetected") 71 | static let historyUpdated = NSNotification.Name("HistoryUpdated") 72 | static let permissionChanged = NSNotification.Name("PermissionChanged") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /PreferencesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferencesManager.swift 3 | // OTPExtractor 4 | // 5 | // Created by Error 6 | // 7 | 8 | import Foundation 9 | 10 | /// Manages user preferences and settings 11 | class PreferencesManager { 12 | static let shared = PreferencesManager() 13 | 14 | private let defaults = UserDefaults.standard 15 | 16 | private init() { 17 | registerDefaults() 18 | } 19 | 20 | /// Register default values for all preferences 21 | private func registerDefaults() { 22 | defaults.register(defaults: [ 23 | Constants.UserDefaultsKeys.pollingInterval: Constants.pollingInterval, 24 | Constants.UserDefaultsKeys.maxHistorySize: Constants.defaultMaxHistorySize, 25 | Constants.UserDefaultsKeys.autoClipboardClear: true, 26 | Constants.UserDefaultsKeys.clipboardClearDelay: Constants.clipboardClearDelay, 27 | Constants.UserDefaultsKeys.notificationsEnabled: true, 28 | Constants.UserDefaultsKeys.soundEnabled: true 29 | ]) 30 | } 31 | 32 | // MARK: - Polling Interval 33 | 34 | var pollingInterval: TimeInterval { 35 | get { defaults.double(forKey: Constants.UserDefaultsKeys.pollingInterval) } 36 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.pollingInterval) } 37 | } 38 | 39 | // MARK: - History 40 | 41 | var maxHistorySize: Int { 42 | get { defaults.integer(forKey: Constants.UserDefaultsKeys.maxHistorySize) } 43 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.maxHistorySize) } 44 | } 45 | 46 | // MARK: - Clipboard 47 | 48 | var autoClipboardClear: Bool { 49 | get { defaults.bool(forKey: Constants.UserDefaultsKeys.autoClipboardClear) } 50 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.autoClipboardClear) } 51 | } 52 | 53 | var clipboardClearDelay: TimeInterval { 54 | get { defaults.double(forKey: Constants.UserDefaultsKeys.clipboardClearDelay) } 55 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.clipboardClearDelay) } 56 | } 57 | 58 | // MARK: - Notifications 59 | 60 | var notificationsEnabled: Bool { 61 | get { defaults.bool(forKey: Constants.UserDefaultsKeys.notificationsEnabled) } 62 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.notificationsEnabled) } 63 | } 64 | 65 | var soundEnabled: Bool { 66 | get { defaults.bool(forKey: Constants.UserDefaultsKeys.soundEnabled) } 67 | set { defaults.set(newValue, forKey: Constants.UserDefaultsKeys.soundEnabled) } 68 | } 69 | 70 | /// Reset all preferences to default values 71 | func resetToDefaults() { 72 | let domain = Bundle.main.bundleIdentifier! 73 | defaults.removePersistentDomain(forName: domain) 74 | registerDefaults() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | 64 | # Code Injection 65 | # 66 | # After new code Injection tools there's a generated folder /iOSInjectionProject 67 | # https://github.com/johnno1962/injectionforxcode 68 | 69 | iOSInjectionProject/ 70 | 71 | # Xcode Build 72 | build/ 73 | DerivedData/ 74 | *.moved-aside 75 | *.pbxuser 76 | !default.pbxuser 77 | *.mode1v3 78 | !default.mode1v3 79 | *.mode2v3 80 | !default.mode2v3 81 | *.perspectivev3 82 | !default.perspectivev3 83 | *.xcscmblueprint 84 | *.xccheckout 85 | 86 | # macOS 87 | .DS_Store 88 | .AppleDouble 89 | .LSOverride 90 | 91 | # Thumbnails 92 | ._* 93 | 94 | # Files that might appear in the root of a volume 95 | .DocumentRevisions-V100 96 | .fseventsd 97 | .Spotlight-V100 98 | .TemporaryItems 99 | .Trashes 100 | .VolumeIcon.icns 101 | .com.apple.timemachine.donotpresent 102 | 103 | # Directories potentially created on remote AFP share 104 | .AppleDB 105 | .AppleDesktop 106 | Network Trash Folder 107 | Temporary Items 108 | .apdisk 109 | 110 | # SPM 111 | .swiftpm/ 112 | Packages/ 113 | Package.pins 114 | Package.resolved 115 | 116 | # App-specific 117 | *.dmg 118 | *.zip 119 | release/ 120 | dist/ 121 | -------------------------------------------------------------------------------- /PreferencesWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferencesWindow.swift 3 | // OTPExtractor 4 | // 5 | // Created by Error 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Preferences window for configuring app settings 11 | struct PreferencesView: View { 12 | @State private var pollingInterval: Double 13 | @State private var maxHistorySize: Int 14 | @State private var autoClipboardClear: Bool 15 | @State private var clipboardClearDelay: Double 16 | @State private var notificationsEnabled: Bool 17 | @State private var soundEnabled: Bool 18 | 19 | private let prefs = PreferencesManager.shared 20 | 21 | init() { 22 | _pollingInterval = State(initialValue: PreferencesManager.shared.pollingInterval) 23 | _maxHistorySize = State(initialValue: PreferencesManager.shared.maxHistorySize) 24 | _autoClipboardClear = State(initialValue: PreferencesManager.shared.autoClipboardClear) 25 | _clipboardClearDelay = State(initialValue: PreferencesManager.shared.clipboardClearDelay) 26 | _notificationsEnabled = State(initialValue: PreferencesManager.shared.notificationsEnabled) 27 | _soundEnabled = State(initialValue: PreferencesManager.shared.soundEnabled) 28 | } 29 | 30 | var body: some View { 31 | VStack(alignment: .leading, spacing: 20) { 32 | Text("OTP Extractor Preferences") 33 | .font(.title2) 34 | .fontWeight(.bold) 35 | 36 | GroupBox(label: Text("Monitoring")) { 37 | VStack(alignment: .leading, spacing: 10) { 38 | HStack { 39 | Text("Polling Interval:") 40 | Slider(value: $pollingInterval, in: 1...30, step: 1) 41 | Text("\(Int(pollingInterval))s") 42 | .frame(width: 35) 43 | } 44 | Text("How often to check for new OTP messages") 45 | .font(.caption) 46 | .foregroundColor(.secondary) 47 | } 48 | .padding(.vertical, 5) 49 | } 50 | 51 | GroupBox(label: Text("History")) { 52 | VStack(alignment: .leading, spacing: 10) { 53 | Stepper(value: $maxHistorySize, in: 1...10) { 54 | Text("Keep last \(maxHistorySize) codes") 55 | } 56 | Text("Number of recent OTP codes to display in menu") 57 | .font(.caption) 58 | .foregroundColor(.secondary) 59 | } 60 | .padding(.vertical, 5) 61 | } 62 | 63 | GroupBox(label: Text("Security")) { 64 | VStack(alignment: .leading, spacing: 10) { 65 | Toggle("Auto-clear clipboard", isOn: $autoClipboardClear) 66 | 67 | if autoClipboardClear { 68 | HStack { 69 | Text("Clear after:") 70 | Slider(value: $clipboardClearDelay, in: 10...300, step: 10) 71 | Text("\(Int(clipboardClearDelay))s") 72 | .frame(width: 40) 73 | } 74 | .padding(.leading, 20) 75 | } 76 | 77 | Text("Automatically clear clipboard after specified time") 78 | .font(.caption) 79 | .foregroundColor(.secondary) 80 | } 81 | .padding(.vertical, 5) 82 | } 83 | 84 | GroupBox(label: Text("Notifications")) { 85 | VStack(alignment: .leading, spacing: 10) { 86 | Toggle("Show notifications", isOn: $notificationsEnabled) 87 | Toggle("Play sound", isOn: $soundEnabled) 88 | 89 | Text("Alert when an OTP code is detected") 90 | .font(.caption) 91 | .foregroundColor(.secondary) 92 | } 93 | .padding(.vertical, 5) 94 | } 95 | 96 | Divider() 97 | 98 | HStack { 99 | Button("Reset to Defaults") { 100 | resetToDefaults() 101 | } 102 | 103 | Spacer() 104 | 105 | Button("Close") { 106 | saveAndClose() 107 | } 108 | .keyboardShortcut(.defaultAction) 109 | } 110 | 111 | Text("Version \(Constants.appVersion)") 112 | .font(.caption) 113 | .foregroundColor(.secondary) 114 | } 115 | .padding(20) 116 | .frame(width: 450) 117 | } 118 | 119 | private func resetToDefaults() { 120 | prefs.resetToDefaults() 121 | pollingInterval = prefs.pollingInterval 122 | maxHistorySize = prefs.maxHistorySize 123 | autoClipboardClear = prefs.autoClipboardClear 124 | clipboardClearDelay = prefs.clipboardClearDelay 125 | notificationsEnabled = prefs.notificationsEnabled 126 | soundEnabled = prefs.soundEnabled 127 | } 128 | 129 | private func saveAndClose() { 130 | prefs.pollingInterval = pollingInterval 131 | prefs.maxHistorySize = maxHistorySize 132 | prefs.autoClipboardClear = autoClipboardClear 133 | prefs.clipboardClearDelay = clipboardClearDelay 134 | prefs.notificationsEnabled = notificationsEnabled 135 | prefs.soundEnabled = soundEnabled 136 | 137 | // Notify the app to reload settings 138 | NotificationCenter.default.post(name: Notification.Name("PreferencesChanged"), object: nil) 139 | 140 | // Close the window 141 | NSApplication.shared.keyWindow?.close() 142 | } 143 | } 144 | 145 | #Preview { 146 | PreferencesView() 147 | } 148 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Build Instructions for OTP Extractor 2 | 3 | ## Prerequisites 4 | 5 | - macOS 13.0 (Ventura) or later 6 | - Xcode 14.0 or later 7 | - Command Line Tools installed: `xcode-select --install` 8 | 9 | ## Initial Setup (First Time Only) 10 | 11 | Since there's no `.xcodeproj` file yet, you need to create the Xcode project: 12 | 13 | ### Option 1: Create Project in Xcode (Recommended) 14 | 15 | 1. **Open Xcode** 16 | 2. **File > New > Project** 17 | 3. Choose **macOS > App** 18 | 4. Configure: 19 | - Product Name: `OTPExtractor` 20 | - Team: Your Apple Developer Team 21 | - Organization Identifier: `com.error` (or your own) 22 | - Interface: **SwiftUI** 23 | - Language: **Swift** 24 | - Use Core Data: **NO** 25 | - Include Tests: **Optional** 26 | 27 | 5. **Save in the project directory** (`macos-otp-extractor`) 28 | 29 | 6. **Delete the auto-generated files:** 30 | - Delete `ContentView.swift` (if created) 31 | - Delete the default app file if it conflicts 32 | 33 | 7. **Add existing files to the project:** 34 | - Drag all `.swift` files into the project: 35 | - `OTPExtractorApp.swift` 36 | - `Constants.swift` 37 | - `ClipboardManager.swift` 38 | - `PreferencesManager.swift` 39 | - `PreferencesWindow.swift` 40 | - Add `Assets.xcassets` 41 | - Add `OTPExtractor.entitlements` 42 | 43 | 8. **Add SQLite.swift Package:** 44 | - File > Add Package Dependencies 45 | - Enter URL: `https://github.com/stephencelis/SQLite.swift.git` 46 | - Version: Up to Next Major (recommended) 47 | - Add to target: `OTPExtractor` 48 | 49 | 9. **Configure Build Settings:** 50 | - Select project in navigator 51 | - Go to "Signing & Capabilities" 52 | - Select your Team 53 | - Ensure "App Sandbox" is set correctly (should be disabled for Full Disk Access) 54 | - Add entitlements file: `OTPExtractor.entitlements` 55 | 56 | 10. **Build the app:** 57 | ```bash 58 | ⌘B or Product > Build 59 | ``` 60 | 61 | ### Option 2: Create Using Swift Package (Advanced) 62 | 63 | Create a `Package.swift` file: 64 | 65 | ```swift 66 | // swift-tools-version: 5.9 67 | import PackageDescription 68 | 69 | let package = Package( 70 | name: "OTPExtractor", 71 | platforms: [.macOS(.v13)], 72 | dependencies: [ 73 | .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0") 74 | ], 75 | targets: [ 76 | .executableTarget( 77 | name: "OTPExtractor", 78 | dependencies: [ 79 | .product(name: "SQLite", package: "SQLite.swift") 80 | ], 81 | path: ".", 82 | sources: [ 83 | "OTPExtractorApp.swift", 84 | "Constants.swift", 85 | "ClipboardManager.swift", 86 | "PreferencesManager.swift", 87 | "PreferencesWindow.swift" 88 | ] 89 | ) 90 | ] 91 | ) 92 | ``` 93 | 94 | Then build with: 95 | ```bash 96 | swift build -c release 97 | ``` 98 | 99 | **Note:** This creates a command-line executable, not a proper .app bundle. Option 1 is recommended for menu bar apps. 100 | 101 | ## Building from Command Line 102 | 103 | Once the Xcode project is set up: 104 | 105 | ```bash 106 | # Clean build folder 107 | xcodebuild clean -project OTPExtractor.xcodeproj -scheme OTPExtractor 108 | 109 | # Build for release 110 | xcodebuild -project OTPExtractor.xcodeproj \ 111 | -scheme OTPExtractor \ 112 | -configuration Release \ 113 | -derivedDataPath build \ 114 | build 115 | 116 | # The .app will be in: 117 | # build/Build/Products/Release/OTPExtractor.app 118 | ``` 119 | 120 | ## Code Signing 121 | 122 | For distribution, you need to sign the app: 123 | 124 | ```bash 125 | # Sign the app 126 | codesign --force --deep --sign "Developer ID Application: Your Name" \ 127 | build/Build/Products/Release/OTPExtractor.app 128 | 129 | # Verify signature 130 | codesign --verify --deep --strict --verbose=2 \ 131 | build/Build/Products/Release/OTPExtractor.app 132 | 133 | # Check entitlements 134 | codesign -d --entitlements - build/Build/Products/Release/OTPExtractor.app 135 | ``` 136 | 137 | ## Creating a DMG for Distribution 138 | 139 | ```bash 140 | # Create a DMG 141 | hdiutil create -volname "OTP Extractor" \ 142 | -srcfolder build/Build/Products/Release/OTPExtractor.app \ 143 | -ov -format UDZO \ 144 | OTPExtractor.dmg 145 | ``` 146 | 147 | ## Troubleshooting 148 | 149 | ### Build Errors 150 | 151 | 1. **"Cannot find 'Connection' in scope"** 152 | - Solution: Make sure SQLite.swift package is added 153 | 154 | 2. **"No such module 'SQLite'"** 155 | - Solution: Clean build folder and rebuild 156 | 157 | 3. **Code signing issues** 158 | - Solution: Set your Team in Signing & Capabilities 159 | 160 | ### Runtime Issues 161 | 162 | 1. **App crashes on launch** 163 | - Check Console.app for crash logs 164 | - Verify all .swift files are included in target 165 | 166 | 2. **Can't read Messages database** 167 | - Grant Full Disk Access in System Settings 168 | - Restart the app after granting permission 169 | 170 | ## Testing 171 | 172 | After building: 173 | 174 | 1. Copy app to `/Applications/` 175 | 2. Grant Full Disk Access: 176 | - System Settings > Privacy & Security > Full Disk Access 177 | - Add OTPExtractor.app 178 | 3. Launch the app 179 | 4. Check menu bar for key icon 180 | 5. Test with a verification code message 181 | 182 | ## Distribution Checklist 183 | 184 | - [ ] Code is signed with Developer ID 185 | - [ ] App is notarized by Apple (for public distribution) 186 | - [ ] DMG created and tested 187 | - [ ] README updated with download link 188 | - [ ] GitHub release created with: 189 | - DMG file 190 | - SHA256 checksum 191 | - Release notes from CHANGELOG 192 | 193 | ## Automated Build Script 194 | 195 | Save this as `build.sh`: 196 | 197 | ```bash 198 | #!/bin/bash 199 | set -e 200 | 201 | echo "🔨 Building OTP Extractor..." 202 | 203 | # Clean 204 | xcodebuild clean -project OTPExtractor.xcodeproj -scheme OTPExtractor 205 | 206 | # Build 207 | xcodebuild -project OTPExtractor.xcodeproj \ 208 | -scheme OTPExtractor \ 209 | -configuration Release \ 210 | -derivedDataPath build \ 211 | build 212 | 213 | # Sign (optional - replace with your identity) 214 | # codesign --force --deep --sign "Developer ID Application: Your Name" \ 215 | # build/Build/Products/Release/OTPExtractor.app 216 | 217 | echo "✅ Build complete!" 218 | echo "📦 App location: build/Build/Products/Release/OTPExtractor.app" 219 | 220 | # Optional: Open in Finder 221 | open build/Build/Products/Release/ 222 | ``` 223 | 224 | Make executable: `chmod +x build.sh` 225 | 226 | ## Quick Reference 227 | 228 | | Command | Description | 229 | |---------|-------------| 230 | | `⌘B` | Build | 231 | | `⌘R` | Run | 232 | | `⌘.` | Stop | 233 | | `⌘K` | Clean build folder | 234 | | `⌘U` | Run tests | 235 | 236 | --- 237 | 238 | For questions or issues, see the main README.md or open an issue on GitHub. 239 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting Guide for OTP Extractor 2 | 3 | ## Common Issues When Running from Xcode 4 | 5 | ### Issue 0: App Looking in Wrong Directory (Sandbox Container Path) 6 | 7 | **Symptom:** Console shows: 8 | ``` 9 | Checking Full Disk Access for: /Users/username/Library/Containers/com.something.OTPExtractor/Data/Library/Messages/chat.db 10 | ``` 11 | 12 | **Cause:** The app is running in an App Sandbox, which redirects file system access to a container directory instead of the real home directory. 13 | 14 | **Solution: Disable App Sandbox in Xcode** 15 | 16 | 1. **Select the project** in Xcode's navigator (left panel) 17 | 2. **Select the OTPExtractor target** 18 | 3. Go to **Signing & Capabilities** tab 19 | 4. Find **"App Sandbox"** capability 20 | 5. If it exists, **click the X button** to remove it completely 21 | 6. **Clean build folder** (⌘⇧K) 22 | 7. **Rebuild** (⌘B) 23 | 8. **Run again** (⌘R) 24 | 25 | You should now see: 26 | ``` 27 | Real home directory: /Users/username 28 | Messages database path: /Users/username/Library/Messages/chat.db 29 | ``` 30 | 31 | **Alternative:** The latest code update includes a fix that detects the real home directory even when sandboxed, but it's still best to disable the sandbox for this app since it needs Full Disk Access anyway. 32 | 33 | **Verify Entitlements File:** 34 | 35 | The `OTPExtractor.entitlements` file should be **minimal or empty** (just an empty `` element). 36 | 37 | **Correct entitlements file:** 38 | ```xml 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ``` 47 | 48 | **Important:** Even having `com.apple.security.app-sandbox` set to `false` can sometimes cause issues. The cleanest solution is an empty dict as shown above. 49 | 50 | Ensure the entitlements file is properly linked: 51 | 1. Select project > Target > Build Settings 52 | 2. Search for "Code Signing Entitlements" 53 | 3. Should show: `OTPExtractor.entitlements` 54 | 55 | If you still have issues, try: 56 | 1. Remove any existing entitlements in the Xcode UI (Signing & Capabilities tab) 57 | 2. Verify the `.entitlements` file is truly minimal (as shown above) 58 | 3. Clean build and rebuild 59 | 60 | --- 61 | 62 | ### Issue 1: "Full Disk Access denied" (Even Though It's Enabled) 63 | 64 | **Cause:** When you run the app from Xcode, it executes from a temporary DerivedData folder, not from `/Applications`. Full Disk Access permissions are tied to the **specific binary path**. 65 | 66 | **Solution:** 67 | 68 | #### Option A: Add the Xcode Build to Full Disk Access (For Testing) 69 | 70 | 1. **Run the app from Xcode** (⌘R) 71 | 2. **Check the Console logs** - Look for the line: 72 | ``` 73 | App running from: /Users/YOUR_USERNAME/Library/Developer/Xcode/DerivedData/OTPExtractor-XXXXXX/Build/Products/Debug/OTPExtractor.app 74 | ``` 75 | 3. **Copy that exact path** 76 | 4. **Open System Settings** > Privacy & Security > Full Disk Access 77 | 5. Click the **lock icon** to unlock (may need admin password) 78 | 6. Click the **`+` button** 79 | 7. Press **⌘⇧G** (Go to Folder) and paste the path 80 | 8. Navigate to and select `OTPExtractor.app` 81 | 9. **Enable the toggle** next to the app 82 | 10. **Stop and restart** the app in Xcode (⌘. then ⌘R) 83 | 84 | **⚠️ Important:** The DerivedData path changes with each clean build! If you clean your project, you'll need to re-add the new path. 85 | 86 | #### Option B: Build and Run from /Applications (Recommended) 87 | 88 | 1. **Build in Xcode** (⌘B) 89 | 2. **Product** > Show Build Folder in Finder 90 | 3. **Copy** `OTPExtractor.app` to `/Applications/` 91 | 4. **Open System Settings** > Privacy & Security > Full Disk Access 92 | 5. Click **`+`** and add `/Applications/OTPExtractor.app` 93 | 6. **Enable the toggle** 94 | 7. **Launch the app** from `/Applications/` (not from Xcode) 95 | 8. **Restart the app** if needed 96 | 97 | This method is more stable because the path doesn't change. 98 | 99 | --- 100 | 101 | ### Issue 2: "Notifications are not allowed for this application" 102 | 103 | **Cause:** Notification permissions require: 104 | - Proper app signing 105 | - The app to be registered with the system (notarized or from a known location) 106 | 107 | **Solutions:** 108 | 109 | #### Quick Fix: Reset Notification Permissions 110 | 111 | 1. **Quit the app completely** 112 | 2. Run this command in Terminal: 113 | ```bash 114 | tccutil reset Notifications com.error.OTPExtractor 115 | ``` 116 | (Replace `com.error.OTPExtractor` with your actual bundle identifier) 117 | 3. **Restart the app** - it will prompt for notification permission again 118 | 119 | #### Alternative: Check System Settings 120 | 121 | 1. **System Settings** > Notifications 122 | 2. Scroll down and find **OTPExtractor** 123 | 3. If it's not there, the app isn't properly registered 124 | 4. **Enable "Allow Notifications"** if present 125 | 5. Select notification style: **Banners** (recommended) 126 | 6. **Restart the app** 127 | 128 | #### For Development Builds: 129 | 130 | Running unsigned apps from Xcode may cause notification issues. Try: 131 | 132 | 1. **Enable code signing** in Xcode: 133 | - Select project in navigator 134 | - Go to **Signing & Capabilities** 135 | - Select your **Team** 136 | - Enable **"Automatically manage signing"** 137 | 138 | 2. **Or disable notifications during development:** 139 | - Set `notificationsEnabled` to `false` in preferences 140 | - This won't affect the core OTP extraction functionality 141 | 142 | --- 143 | 144 | ### Issue 3: App Crashes or Doesn't Appear in Menu Bar 145 | 146 | **Check Console for Errors:** 147 | 148 | 1. Open **Console.app** (in `/Applications/Utilities/`) 149 | 2. Filter by: `process:OTPExtractor` 150 | 3. Look for crash reports or error messages 151 | 4. Common issues: 152 | - Missing SQLite.swift package 153 | - Code signing issues 154 | - Entitlements misconfigured 155 | 156 | **Verify SQLite.swift is Added:** 157 | 158 | 1. In Xcode, select the project 159 | 2. Select the **OTPExtractor target** 160 | 3. Go to **General** > Frameworks, Libraries, and Embedded Content 161 | 4. Verify **SQLite** is listed 162 | 5. If not, go to **File** > Add Package Dependencies 163 | 6. Add: `https://github.com/stephencelis/SQLite.swift.git` 164 | 165 | --- 166 | 167 | ### Issue 4: App Asks for Permission Every Launch 168 | 169 | **Cause:** The app path keeps changing (common with Xcode DerivedData) 170 | 171 | **Solution:** Use Option B from Issue 1 - run from `/Applications/` instead of Xcode. 172 | 173 | --- 174 | 175 | ### Issue 5: No OTPs Detected Even with Permissions 176 | 177 | **Debugging Steps:** 178 | 179 | 1. **Check Console logs** for: 180 | ``` 181 | Checking Full Disk Access for: /Users/YOUR_USERNAME/Library/Messages/chat.db 182 | Can read Messages database: true 183 | ``` 184 | 185 | 2. **Send yourself a test OTP:** 186 | - Use a service that sends verification codes 187 | - Watch the Console for "OTP Found" messages 188 | 189 | 3. **Try manual fetch:** 190 | - Click menu bar icon 191 | - Select "Fetch Last OTP Manually" 192 | - Check if alert shows "No OTP Found" or if code is copied 193 | 194 | 4. **Verify Message Format:** 195 | - The app looks for keywords like: code, verification, OTP, 2FA, etc. 196 | - The OTP must be 4-9 digits or 6-8 alphanumeric characters 197 | - Test with a message like: "Your verification code is 123456" 198 | 199 | --- 200 | 201 | ## Quick Diagnostic Checklist 202 | 203 | Run through this checklist when troubleshooting: 204 | 205 | - [ ] **Console shows the app path** - Note it down 206 | - [ ] **Full Disk Access granted** for the **exact app path** 207 | - [ ] **App restarted** after granting permissions 208 | - [ ] **Messages database path** is correct (check Console) 209 | - [ ] **Can read database** = `true` in Console logs 210 | - [ ] **Notification permissions** granted (or disabled in preferences) 211 | - [ ] **SQLite package** is added and linked 212 | - [ ] **Test message sent** with clear OTP format 213 | 214 | --- 215 | 216 | ## Getting Detailed Logs 217 | 218 | To see all debug information: 219 | 220 | 1. **Open Console.app** 221 | 2. Click **Action** > Include Info Messages 222 | 3. Click **Action** > Include Debug Messages 223 | 4. Filter by: `subsystem:com.error.OTPExtractor` 224 | 5. **Run the app** and watch for detailed logs 225 | 226 | Key log messages to look for: 227 | ``` 228 | ✅ App running from: [path] 229 | ✅ OTP regex patterns compiled successfully 230 | ✅ Checking Full Disk Access for: [database path] 231 | ✅ Can read Messages database: true 232 | ✅ Monitoring started. Last message ID: [number] 233 | ✅ File system monitoring setup successfully 234 | ✅ OTP Found: [code] from [sender] 235 | ``` 236 | 237 | --- 238 | 239 | ## Still Having Issues? 240 | 241 | 1. **Clean build folder**: ⌘K in Xcode, then rebuild 242 | 2. **Reset all permissions**: 243 | ```bash 244 | tccutil reset All com.error.OTPExtractor 245 | ``` 246 | 3. **Check macOS version**: Requires macOS 13.0+ (Ventura or later) 247 | 4. **Verify Messages app works**: Make sure iMessage/SMS is receiving messages 248 | 5. **Check bundle identifier**: Must match in Xcode and System Settings 249 | 250 | --- 251 | 252 | ## Testing Without Messages Database 253 | 254 | To verify the app is working without needing Full Disk Access, you can temporarily comment out the permission check for testing: 255 | 256 | **DO NOT SHIP WITH THIS CHANGE - FOR TESTING ONLY** 257 | 258 | This is just to verify the app launches and UI works correctly. 259 | 260 | --- 261 | 262 | ## Production Checklist 263 | 264 | Before distributing your app: 265 | 266 | - [ ] Code signed with Developer ID 267 | - [ ] Notarized by Apple 268 | - [ ] Hardened runtime enabled 269 | - [ ] Entitlements properly configured 270 | - [ ] Tested on clean macOS install 271 | - [ ] Full Disk Access instructions in README 272 | - [ ] Privacy policy included (if needed) 273 | 274 | --- 275 | 276 | For more help, check the main README.md or open an issue on GitHub. 277 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OTP Extractor for macOS 2 | 3 | A lightweight and efficient macOS menu bar utility that automatically reads One-Time Passcodes (OTPs) from the Messages app and copies them to the clipboard. 4 | 5 | ## Overview 6 | 7 | This application lives in your macOS menu bar and silently monitors for new messages that contain verification codes. When a new OTP is detected, it's instantly copied to your clipboard, saving you the time and effort of switching to the Messages app, finding the code, and copying it manually. 8 | 9 | ## Features 10 | 11 | ### Core Functionality 12 | * **Automatic OTP Detection:** Uses advanced regex patterns to find and extract 4-9 digit codes and 6-8 character alphanumeric codes from incoming messages 13 | * **Clipboard Integration:** Automatically copies the found OTP to the system clipboard 14 | * **Smart Detection:** Two-step keyword + regex approach minimizes false positives 15 | * **Multi-Language Support:** Detects OTPs in English and Hebrew messages 16 | 17 | ### Security 18 | * **Auto-Clear Clipboard:** Optionally clears the clipboard after a configurable delay (default: 60 seconds) for enhanced security 19 | * **Read-Only Access:** Uses read-only database access - never modifies your messages 20 | * **Privacy-Focused:** All processing happens locally on your device 21 | 22 | ### User Experience 23 | * **Rich OTP History:** The menu displays recent codes with sender and time information 24 | * **Configurable History Size:** Keep 1-10 recent codes (default: 3) 25 | * **Visual Feedback:** The menu bar icon provides instant visual feedback with color-coded animations 26 | * **Notifications:** Optional system notifications when OTP codes are detected 27 | * **Auditory Feedback:** Optional system sound confirmation 28 | * **Manual Fetch:** Menu option to manually trigger a check for the last OTP 29 | 30 | ### Performance 31 | * **File System Events:** Uses FSEvents API for efficient, battery-friendly monitoring 32 | * **Optimized Regex:** Pre-compiled regex patterns for maximum performance 33 | * **Configurable Polling:** Adjust monitoring frequency (1-30 seconds) in preferences 34 | 35 | ### Customization 36 | * **Preferences Window:** Easy-to-use settings interface (⌘,) 37 | * **Adjustable Polling Interval:** Fine-tune monitoring frequency 38 | * **Notification Controls:** Toggle notifications and sounds independently 39 | * **History Management:** Configurable history size with clear confirmation 40 | 41 | ## What's New in v1.3.0 42 | 43 | ### Major Improvements 44 | - ✅ **Alphanumeric OTP Support** - Now detects codes like "ABC123" and "G-123456" 45 | - ✅ **FSEvents Monitoring** - 50-70% reduction in CPU usage with file system event monitoring 46 | - ✅ **Auto-Clear Clipboard** - Enhanced security with configurable auto-clear (10-300 seconds) 47 | - ✅ **Preferences Window** - Full settings UI accessible via ⌘, or menu 48 | - ✅ **User Notifications** - Optional system notifications for OTP detection 49 | - ✅ **Expanded Hebrew Support** - Added "סיסמתך" and improved Hebrew keyword detection 50 | - ✅ **Confirmation Dialogs** - Prevent accidental history clearing 51 | - ✅ **Manual Fetch Feedback** - Shows alert when no OTP is found 52 | - ✅ **Version Display** - See current version in menu bar 53 | 54 | ### Code Quality Improvements 55 | - ✅ **Performance** - Pre-compiled regex patterns (no runtime compilation overhead) 56 | - ✅ **Memory Management** - Proper weak references prevent retain cycles 57 | - ✅ **Error Handling** - Comprehensive error handling with os.log integration 58 | - ✅ **Code Organization** - Modular architecture with separate manager classes 59 | - ✅ **Documentation** - Inline documentation for all major methods 60 | - ✅ **Constants Management** - All magic numbers extracted to Constants.swift 61 | - ✅ **No Code Duplication** - Centralized ClipboardManager 62 | 63 | ### Bug Fixes 64 | - Fixed inefficient `.first(where: { _ in true })` usage 65 | - Removed unused AVFoundation import 66 | - Fixed OTP pattern to support 4-digit PINs and 9-digit codes 67 | - Better date conversion using proper constants 68 | 69 | ## Download 70 | 71 | For users who don't want to build from source, you can download the pre-compiled application here. 72 | 73 | * [**Download OTP Extractor v1.3**](https://github.com/error2/macos-otp-extractor/releases/download/v1.3/OTPExtractor.app.zip) ✨ **Latest Release** 74 | * [**Download OTP Extractor v1.2**](https://github.com/error2/macos-otp-extractor/releases/download/v1.2/OTPExtractor.app.zip) *(Previous Version)* 75 | 76 | You can also visit the [Releases page](https://github.com/error2/macos-otp-extractor/releases) for all available versions. 77 | 78 | ## How It Works 79 | 80 | The app works by directly and securely reading the local `chat.db` SQLite database where the macOS Messages app stores all message history. It uses two complementary methods: 81 | 82 | 1. **FSEvents Monitoring** - Watches the database file for changes and triggers immediate checks 83 | 2. **Timer-Based Polling** - Fallback polling at configurable intervals (default: 5 seconds) 84 | 85 | When a message arrives, the app: 86 | 1. Checks for OTP-related keywords (fast filtering) 87 | 2. Applies regex patterns to extract numeric or alphanumeric codes 88 | 3. Copies the code to clipboard with optional auto-clear 89 | 4. Updates the history and provides visual/audio/notification feedback 90 | 91 | This method is read-only and does not modify any of your messages or data. 92 | 93 | ## Setup & Installation (from Source) 94 | 95 | If you prefer to build the app yourself, follow these instructions. 96 | 97 | ### Prerequisites 98 | 99 | * A Mac running macOS 13.0 (Ventura) or later 100 | * Xcode 14.0 or later 101 | 102 | ### Build Instructions 103 | 104 | 1. **Clone the Repository:** 105 | ```bash 106 | git clone https://github.com/error2/macos-otp-extractor.git 107 | cd macos-otp-extractor 108 | ``` 109 | 110 | 2. **Open in Xcode:** 111 | Open the `.xcodeproj` file in Xcode. 112 | 113 | 3. **Add Dependencies:** 114 | In Xcode, go to **File > Add Package Dependencies...** and add the `SQLite.swift` package: 115 | ``` 116 | https://github.com/stephencelis/SQLite.swift.git 117 | ``` 118 | 119 | 4. **Build the App:** 120 | From the menu bar, select **Product > Build** (or press **⌘B**). 121 | 122 | ### Permissions (Crucial Step) 123 | 124 | For the app to read your messages, you must grant it **Full Disk Access**. 125 | 126 | 1. After building the app, find `OTPExtractor.app` in the **Products** folder in Xcode's Project Navigator. Right-click and select **"Show in Finder"**. 127 | 2. Open **System Settings > Privacy & Security > Full Disk Access**. 128 | 3. Click the `+` button and add the `OTPExtractor.app` file. 129 | 4. Ensure the toggle next to the app is enabled. 130 | 5. **Restart the app** after granting permissions. 131 | 132 | ### Notification Permissions 133 | 134 | On first launch, the app will request permission to send notifications. This is optional but recommended for the best experience. 135 | 136 | ### Final Installation 137 | 138 | 1. Drag the `OTPExtractor.app` file from the build folder into your main `/Applications` folder. 139 | 2. (Recommended) To have the app launch on startup, go to **System Settings > General > Login Items** and add `OTPExtractor` to the list of apps to open at login. 140 | 141 | ## Usage 142 | 143 | ### Basic Usage 144 | - Launch the app - it appears in your menu bar with a key icon 145 | - Grant Full Disk Access when prompted 146 | - The app will automatically detect and copy OTP codes from new messages 147 | 148 | ### Accessing Preferences 149 | - Click the menu bar icon and select "Preferences..." (or press ⌘,) 150 | - Customize polling interval, history size, clipboard auto-clear, and notifications 151 | 152 | ### Manual OTP Fetch 153 | - Click the menu bar icon and select "Fetch Last OTP Manually" (or press ⌘F) 154 | - Useful if you missed an OTP or want to re-copy it 155 | 156 | ### Viewing History 157 | - Click the menu bar icon to see recent OTP codes 158 | - Click any history item to copy it again 159 | - Use "Clear History" to remove all saved codes (with confirmation) 160 | 161 | ## Preferences Explained 162 | 163 | ### Monitoring 164 | - **Polling Interval** (1-30s): How often to check for new messages. Lower = more responsive but uses more CPU. Default: 5s. 165 | 166 | ### History 167 | - **Keep last N codes** (1-10): Number of recent codes to display. Default: 3. 168 | 169 | ### Security 170 | - **Auto-clear clipboard**: Automatically clear clipboard after specified time 171 | - **Clear after** (10-300s): Delay before clearing. Default: 60s. 172 | 173 | ### Notifications 174 | - **Show notifications**: Display system notification when OTP detected 175 | - **Play sound**: Play system sound on detection 176 | 177 | ## Supported OTP Formats 178 | 179 | ### Numeric Codes 180 | - 4-9 digits (e.g., 1234, 123456, 123456789) 181 | - Must be standalone numbers (word boundaries) 182 | 183 | ### Alphanumeric Codes 184 | - 6-8 characters, uppercase letters and numbers (e.g., ABC123, G-123456) 185 | - Must contain at least one digit 186 | 187 | ### Keyword Triggers (English) 188 | code, verification, OTP, 2FA, token, PIN, verify, authentication, confirm, G- 189 | 190 | ### Keyword Triggers (Hebrew) 191 | קוד, סיסמה, סיסמתך, אימות 192 | 193 | ## Technology Stack 194 | 195 | * **Language:** Swift 196 | * **UI Framework:** SwiftUI (preferences) and AppKit (menu bar) 197 | * **Database:** [SQLite.swift](https://github.com/stephencelis/SQLite.swift) for Messages database interaction 198 | * **Logging:** Unified Logging System (os.log) for performance and debugging 199 | * **File Monitoring:** DispatchSource with FSEvents for efficient file watching 200 | 201 | ## Project Structure 202 | 203 | ``` 204 | OTPExtractor/ 205 | ├── OTPExtractorApp.swift # Main app, AppDelegate, OTPManager 206 | ├── Constants.swift # App-wide constants and configuration 207 | ├── ClipboardManager.swift # Clipboard operations with auto-clear 208 | ├── PreferencesManager.swift # UserDefaults wrapper 209 | ├── PreferencesWindow.swift # SwiftUI preferences interface 210 | ├── OTPExtractor.entitlements # App entitlements 211 | └── Assets.xcassets/ # App icons and assets 212 | ``` 213 | 214 | ## Troubleshooting 215 | 216 | ### App doesn't detect OTPs 217 | 1. Ensure Full Disk Access is granted in System Settings 218 | 2. Restart the app after granting permissions 219 | 3. Check that Messages.app is storing messages locally 220 | 4. Try manual fetch to verify functionality 221 | 222 | ### Clipboard auto-clear not working 223 | 1. Check that auto-clear is enabled in Preferences 224 | 2. Verify the delay is set correctly 225 | 3. Ensure you haven't copied other content (auto-clear only clears if clipboard unchanged) 226 | 227 | ### High CPU usage 228 | 1. Increase polling interval in Preferences (e.g., 10-30 seconds) 229 | 2. FSEvents monitoring should keep CPU usage low automatically 230 | 231 | ### Notifications not showing 232 | 1. Check notification permissions in System Settings > Notifications > OTP Extractor 233 | 2. Enable "Show notifications" in app Preferences 234 | 235 | ## Privacy & Security 236 | 237 | - All processing happens **locally** on your device 238 | - The app has **read-only** access to the Messages database 239 | - No data is sent to external servers 240 | - No analytics or tracking 241 | - Clipboard auto-clear enhances security for sensitive codes 242 | - Open source - audit the code yourself! 243 | 244 | ## Known Limitations 245 | 246 | - Requires Full Disk Access to read Messages database 247 | - Only works with Messages.app (iMessage/SMS) 248 | - May occasionally detect non-OTP numbers if they match the pattern 249 | - Alphanumeric detection limited to 6-8 character codes 250 | 251 | ## Contributing 252 | 253 | Contributions are welcome! Please feel free to submit a Pull Request. 254 | 255 | ### Development Guidelines 256 | - Follow Swift API Design Guidelines 257 | - Add inline documentation for complex logic 258 | - Test on multiple macOS versions 259 | - Maintain existing code style 260 | 261 | ## Future Enhancements 262 | 263 | - [ ] Machine learning for better OTP detection accuracy 264 | - [ ] Support for third-party messaging apps 265 | - [ ] Keychain integration for secure history storage 266 | - [ ] Auto-update mechanism 267 | - [ ] Dark mode icon variants 268 | - [ ] Accessibility improvements 269 | - [ ] Localization for more languages 270 | 271 | ## License 272 | 273 | This project is provided as-is for personal and educational use. 274 | 275 | ## Credits 276 | 277 | Created by Error 278 | Built with ❤️ for the macOS community 279 | 280 | --- 281 | 282 | **⚠️ Disclaimer:** This application accesses your Messages database for legitimate OTP extraction purposes. Please ensure you trust any software that requests Full Disk Access. Always download from official sources or build from source yourself. 283 | 284 | *This project was created for personal use and demonstrates interaction with system files on macOS.* 285 | -------------------------------------------------------------------------------- /OTPExtractorApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OTPExtractorApp.swift 3 | // OTPExtractor 4 | // 5 | // Created by Error 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | import SQLite 11 | import UserNotifications 12 | import os.log 13 | 14 | // MARK: - OTP Information Model 15 | 16 | /// A struct to hold detailed information about each OTP. 17 | struct OTPInfo: Hashable { 18 | let code: String 19 | let sender: String 20 | let date: Date 21 | } 22 | 23 | // MARK: - Main Application 24 | 25 | @main 26 | struct OTPExtractorApp: App { 27 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 28 | 29 | var body: some Scene { 30 | Settings { 31 | PreferencesView() 32 | } 33 | } 34 | } 35 | 36 | // MARK: - AppDelegate 37 | 38 | class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { 39 | var statusItem: NSStatusItem? 40 | var otpManager: OTPManager? 41 | private var hasPermission: Bool = false 42 | private var preferencesWindow: NSWindow? 43 | private let logger = Logger(subsystem: "com.error.OTPExtractor", category: "AppDelegate") 44 | 45 | func applicationDidFinishLaunching(_ aNotification: Notification) { 46 | // Setup notification center 47 | UNUserNotificationCenter.current().delegate = self 48 | requestNotificationPermission() 49 | 50 | // Setup status item 51 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 52 | 53 | if let button = statusItem?.button { 54 | button.image = NSImage(systemSymbolName: "key.viewfinder", accessibilityDescription: "OTP Extractor") 55 | } 56 | 57 | otpManager = OTPManager(statusItem: statusItem) 58 | 59 | // Listen for preferences changes 60 | NotificationCenter.default.addObserver( 61 | self, 62 | selector: #selector(preferencesChanged), 63 | name: Notification.Name("PreferencesChanged"), 64 | object: nil 65 | ) 66 | 67 | checkPermissionsAndSetup() 68 | } 69 | 70 | // MARK: - Notification Permissions 71 | 72 | private func requestNotificationPermission() { 73 | let center = UNUserNotificationCenter.current() 74 | center.requestAuthorization(options: [.alert, .sound]) { granted, error in 75 | if let error = error { 76 | self.logger.error("Notification permission error: \(error.localizedDescription)") 77 | } else { 78 | self.logger.info("Notification permission granted: \(granted)") 79 | } 80 | } 81 | } 82 | 83 | func userNotificationCenter(_ center: UNUserNotificationCenter, 84 | willPresent notification: UNNotification, 85 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { 86 | completionHandler([.banner, .sound]) 87 | } 88 | 89 | // MARK: - Permissions Check 90 | 91 | @objc func checkPermissionsAndSetup() { 92 | hasPermission = otpManager?.hasFullDiskAccess() ?? false 93 | 94 | if hasPermission { 95 | otpManager?.startMonitoring { [weak self] in 96 | DispatchQueue.main.async { 97 | self?.setupMenu() 98 | } 99 | } 100 | otpManager?.fetchLastOTP() 101 | } else { 102 | showPermissionsAlert() 103 | } 104 | 105 | setupMenu() 106 | } 107 | 108 | @objc func preferencesChanged() { 109 | otpManager?.reloadPreferences() 110 | setupMenu() 111 | } 112 | 113 | // MARK: - Menu Setup 114 | 115 | @objc func setupMenu() { 116 | let menu = NSMenu() 117 | 118 | if hasPermission { 119 | // --- Full Menu (Permission Granted) --- 120 | if let history = otpManager?.otpHistory, !history.isEmpty { 121 | let historyTitle = NSMenuItem(title: "Recent Codes", action: nil, keyEquivalent: "") 122 | historyTitle.isEnabled = false 123 | menu.addItem(historyTitle) 124 | 125 | let timeFormatter = DateFormatter() 126 | timeFormatter.dateFormat = "h:mm a" 127 | 128 | for info in history { 129 | let formattedTime = timeFormatter.string(from: info.date) 130 | let title = "\(info.code) from \(info.sender) (\(formattedTime))" 131 | let historyItem = NSMenuItem(title: title, action: #selector(copyHistoryItem(_:)), keyEquivalent: "") 132 | historyItem.representedObject = info.code 133 | historyItem.target = self 134 | menu.addItem(historyItem) 135 | } 136 | menu.addItem(NSMenuItem.separator()) 137 | 138 | let clearHistoryItem = NSMenuItem(title: "Clear History", action: #selector(clearHistory), keyEquivalent: "") 139 | clearHistoryItem.target = self 140 | menu.addItem(clearHistoryItem) 141 | 142 | menu.addItem(NSMenuItem.separator()) 143 | } 144 | 145 | let fetchMenuItem = NSMenuItem(title: "Fetch Last OTP Manually", action: #selector(fetchLastOTPManual), keyEquivalent: "F") 146 | fetchMenuItem.target = self 147 | menu.addItem(fetchMenuItem) 148 | } else { 149 | // --- Limited Menu (Permission Denied) --- 150 | let permissionTitle = NSMenuItem(title: "Permission Required", action: nil, keyEquivalent: "") 151 | permissionTitle.isEnabled = false 152 | menu.addItem(permissionTitle) 153 | 154 | let checkAgainItem = NSMenuItem(title: "Check Permissions Again", action: #selector(checkPermissionsAndSetup), keyEquivalent: "") 155 | checkAgainItem.target = self 156 | menu.addItem(checkAgainItem) 157 | } 158 | 159 | menu.addItem(NSMenuItem.separator()) 160 | 161 | // Preferences menu item 162 | let preferencesItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences), keyEquivalent: ",") 163 | preferencesItem.target = self 164 | menu.addItem(preferencesItem) 165 | 166 | menu.addItem(NSMenuItem.separator()) 167 | 168 | // Version display 169 | let versionItem = NSMenuItem(title: "Version \(Constants.appVersion)", action: nil, keyEquivalent: "") 170 | versionItem.isEnabled = false 171 | menu.addItem(versionItem) 172 | 173 | menu.addItem(NSMenuItem.separator()) 174 | 175 | let quitMenuItem = NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q") 176 | quitMenuItem.target = self 177 | menu.addItem(quitMenuItem) 178 | 179 | statusItem?.menu = menu 180 | } 181 | 182 | // MARK: - Alert Dialogs 183 | 184 | /// Shows alert when Full Disk Access permission is required 185 | func showPermissionsAlert() { 186 | let alert = NSAlert() 187 | alert.messageText = "Full Disk Access Required" 188 | alert.informativeText = "OTP Extractor needs Full Disk Access to read codes from the Messages app. Please grant permission in System Settings." 189 | alert.alertStyle = .warning 190 | 191 | let openButton = alert.addButton(withTitle: "Open Settings") 192 | openButton.target = self 193 | openButton.action = #selector(openPrivacySettings) 194 | 195 | alert.addButton(withTitle: "OK") 196 | 197 | alert.runModal() 198 | } 199 | 200 | @objc func openPrivacySettings() { 201 | if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { 202 | NSWorkspace.shared.open(url) 203 | } 204 | } 205 | 206 | // MARK: - Menu Actions 207 | 208 | @objc func copyHistoryItem(_ sender: NSMenuItem) { 209 | if let code = sender.representedObject as? String { 210 | ClipboardManager.shared.copy(text: code, autoClear: PreferencesManager.shared.autoClipboardClear) 211 | if PreferencesManager.shared.soundEnabled { 212 | NSSound(named: "Submarine")?.play() 213 | } 214 | } 215 | } 216 | 217 | @objc func clearHistory() { 218 | // Show confirmation dialog 219 | let alert = NSAlert() 220 | alert.messageText = "Clear History?" 221 | alert.informativeText = "This will remove all saved OTP codes from the menu. This action cannot be undone." 222 | alert.alertStyle = .warning 223 | alert.addButton(withTitle: "Clear") 224 | alert.addButton(withTitle: "Cancel") 225 | 226 | let response = alert.runModal() 227 | if response == .alertFirstButtonReturn { 228 | otpManager?.clearHistory() 229 | setupMenu() 230 | } 231 | } 232 | 233 | @objc func fetchLastOTPManual() { 234 | let foundOTP = otpManager?.fetchLastOTP() ?? false 235 | 236 | if !foundOTP { 237 | // Show feedback when no OTP is found 238 | let alert = NSAlert() 239 | alert.messageText = "No OTP Found" 240 | alert.informativeText = "No recent OTP code was detected in your messages." 241 | alert.alertStyle = .informational 242 | alert.addButton(withTitle: "OK") 243 | alert.runModal() 244 | } 245 | } 246 | 247 | @objc func openPreferences() { 248 | // Check if preferences window already exists 249 | if let window = preferencesWindow, window.isVisible { 250 | window.makeKeyAndOrderFront(nil) 251 | return 252 | } 253 | 254 | // Create new preferences window 255 | let contentView = PreferencesView() 256 | let hostingController = NSHostingController(rootView: contentView) 257 | 258 | let window = NSWindow(contentViewController: hostingController) 259 | window.title = "Preferences" 260 | window.styleMask = [.titled, .closable] 261 | window.center() 262 | window.setFrameAutosaveName("PreferencesWindow") 263 | 264 | preferencesWindow = window 265 | window.makeKeyAndOrderFront(nil) 266 | } 267 | 268 | @objc func quitApp() { 269 | NSApplication.shared.terminate(self) 270 | } 271 | } 272 | 273 | // MARK: - OTP Manager 274 | 275 | class OTPManager { 276 | private var dbPath: String 277 | private var lastCheckedMessageID: Int = 0 278 | private var timer: Timer? 279 | private var fileMonitor: DispatchSourceFileSystemObject? 280 | private weak var statusItem: NSStatusItem? 281 | private let logger = Logger(subsystem: "com.error.OTPExtractor", category: "OTPManager") 282 | 283 | private(set) var otpHistory: [OTPInfo] = [] 284 | 285 | private var onUpdate: (() -> Void)? 286 | 287 | // Pre-compiled regex patterns for performance 288 | private let digitOTPRegex: NSRegularExpression 289 | private let alphanumericOTPRegex: NSRegularExpression 290 | 291 | init(statusItem: NSStatusItem?) { 292 | // Get the REAL user home directory, not the sandboxed container 293 | // This is critical for accessing the Messages database 294 | let realHomeDir = Self.getRealHomeDirectory() 295 | self.dbPath = realHomeDir.appendingPathComponent(Constants.messagesDBPath).path 296 | self.statusItem = statusItem 297 | 298 | // Compile regex patterns once during initialization 299 | do { 300 | // Pattern for 4-9 digit OTP codes 301 | let digitPattern = #"\b(\d{4,9})\b"# 302 | digitOTPRegex = try NSRegularExpression(pattern: digitPattern, options: []) 303 | 304 | // Pattern for alphanumeric codes (6-8 characters, mix of letters and numbers) 305 | let alphanumericPattern = #"\b([A-Z0-9]{6,8})\b"# 306 | alphanumericOTPRegex = try NSRegularExpression(pattern: alphanumericPattern, options: []) 307 | } catch { 308 | // This should never happen with hardcoded patterns, but handle it anyway 309 | fatalError("Failed to compile OTP regex patterns: \(error.localizedDescription)") 310 | } 311 | } 312 | 313 | /// Gets the real user home directory, bypassing App Sandbox container paths 314 | /// - Returns: The actual user home directory URL 315 | private static func getRealHomeDirectory() -> URL { 316 | // Method 1: Try environment variable (most reliable) 317 | if let homeEnv = ProcessInfo.processInfo.environment["HOME"] { 318 | return URL(fileURLWithPath: homeEnv) 319 | } 320 | 321 | // Method 2: Use NSHomeDirectory() which bypasses sandbox 322 | let homeDir = NSHomeDirectory() 323 | if !homeDir.contains("/Containers/") { 324 | return URL(fileURLWithPath: homeDir) 325 | } 326 | 327 | // Method 3: Parse from sandbox path (fallback) 328 | // Sandbox path format: /Users/username/Library/Containers/... 329 | let components = homeDir.split(separator: "/") 330 | if components.count >= 3, components[0].isEmpty, components[1] == "Users" { 331 | let username = String(components[2]) 332 | return URL(fileURLWithPath: "/Users/\(username)") 333 | } 334 | 335 | // Final fallback: Use FileManager (might be sandboxed) 336 | return FileManager.default.homeDirectoryForCurrentUser 337 | } 338 | 339 | // MARK: - Permission Check 340 | 341 | /// Checks if the app has Full Disk Access to read the Messages database 342 | /// - Returns: `true` if the database is readable, `false` otherwise 343 | func hasFullDiskAccess() -> Bool { 344 | return FileManager.default.isReadableFile(atPath: dbPath) 345 | } 346 | 347 | // MARK: - Monitoring 348 | 349 | /// Starts monitoring for new OTP messages using FSEvents for efficiency 350 | /// - Parameter onUpdate: Callback to execute when the UI should be updated 351 | func startMonitoring(onUpdate: @escaping () -> Void) { 352 | guard timer == nil else { return } 353 | 354 | self.onUpdate = onUpdate 355 | self.lastCheckedMessageID = fetchLastMessageID() 356 | 357 | // Setup file system monitoring using FSEvents 358 | setupFileMonitoring() 359 | 360 | // Fallback timer-based polling 361 | let interval = PreferencesManager.shared.pollingInterval 362 | timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in 363 | self?.fetchLastOTP() 364 | } 365 | } 366 | 367 | /// Sets up FSEvents-based file monitoring for the Messages database 368 | private func setupFileMonitoring() { 369 | guard hasFullDiskAccess() else { 370 | logger.warning("Cannot setup file monitoring without Full Disk Access") 371 | return 372 | } 373 | 374 | let fileDescriptor = open(dbPath, O_EVTONLY) 375 | guard fileDescriptor >= 0 else { 376 | logger.error("Failed to open database for monitoring") 377 | return 378 | } 379 | 380 | let source = DispatchSource.makeFileSystemObjectSource( 381 | fileDescriptor: fileDescriptor, 382 | eventMask: [.write, .extend], 383 | queue: DispatchQueue.global(qos: .background) 384 | ) 385 | 386 | source.setEventHandler { [weak self] in 387 | self?.logger.debug("Database file changed, checking for new OTPs") 388 | self?.fetchLastOTP() 389 | } 390 | 391 | source.setCancelHandler { 392 | close(fileDescriptor) 393 | } 394 | 395 | source.resume() 396 | fileMonitor = source 397 | } 398 | 399 | /// Stops all monitoring activities 400 | func stopMonitoring() { 401 | timer?.invalidate() 402 | timer = nil 403 | fileMonitor?.cancel() 404 | fileMonitor = nil 405 | } 406 | 407 | /// Reloads preferences and restarts monitoring if needed 408 | func reloadPreferences() { 409 | let wasMonitoring = timer != nil 410 | stopMonitoring() 411 | 412 | if wasMonitoring, let callback = onUpdate { 413 | startMonitoring(onUpdate: callback) 414 | } 415 | 416 | // Trim history if max size changed 417 | let maxSize = PreferencesManager.shared.maxHistorySize 418 | if otpHistory.count > maxSize { 419 | otpHistory = Array(otpHistory.prefix(maxSize)) 420 | } 421 | } 422 | 423 | /// Clears the OTP history 424 | func clearHistory() { 425 | otpHistory.removeAll() 426 | } 427 | 428 | // MARK: - Database Operations 429 | 430 | /// Fetches the most recent message ID from the database 431 | /// - Returns: The ROWID of the most recent message, or 0 if unavailable 432 | private func fetchLastMessageID() -> Int { 433 | guard hasFullDiskAccess() else { return 0 } 434 | 435 | do { 436 | let db = try Connection(dbPath, readonly: true) 437 | let messageTable = Table("message") 438 | let rowid = Expression("ROWID") 439 | 440 | // Fixed: Use makeIterator().next() for SQLite.swift Row sequence 441 | if let lastMessage = try db.prepare(messageTable.order(rowid.desc).limit(1)).makeIterator().next() { 442 | return lastMessage[rowid] 443 | } 444 | } catch { 445 | logger.error("Failed to get last message ID: \(error.localizedDescription)") 446 | } 447 | return 0 448 | } 449 | 450 | /// Fetches and processes the last OTP from the Messages database 451 | /// - Returns: `true` if an OTP was found and processed, `false` otherwise 452 | @discardableResult 453 | func fetchLastOTP() -> Bool { 454 | guard hasFullDiskAccess() else { 455 | logger.warning("Permission denied. Skipping fetch.") 456 | return false 457 | } 458 | 459 | do { 460 | let db = try Connection(dbPath, readonly: true) 461 | 462 | let messageTable = Table("message") 463 | let handleTable = Table("handle") 464 | 465 | let textCol = Expression("text") 466 | let handleIdCol = Expression("handle_id") 467 | let dateCol = Expression("date") 468 | let handleIdStringCol = Expression("id") 469 | let rowid = Expression("ROWID") 470 | 471 | let query = messageTable 472 | .join(handleTable, on: messageTable[handleIdCol] == handleTable[rowid]) 473 | .select(messageTable[textCol], handleTable[handleIdStringCol], messageTable[dateCol]) 474 | .filter(messageTable[rowid] > lastCheckedMessageID) 475 | .order(messageTable[rowid].desc) 476 | 477 | var otpInfoFound: OTPInfo? 478 | 479 | let newestIdInDb = fetchLastMessageID() 480 | 481 | for message in try db.prepare(query) { 482 | if let text = message[messageTable[textCol]] { 483 | if let code = extractOTP(from: text) { 484 | let sender = message[handleTable[handleIdStringCol]] 485 | let appleEpoch = message[messageTable[dateCol]] 486 | 487 | // Convert Apple epoch to Unix timestamp 488 | let unixEpoch = Double(appleEpoch) / Constants.nanosecondsToSeconds + Constants.appleEpochOffset 489 | let date = Date(timeIntervalSince1970: unixEpoch) 490 | 491 | otpInfoFound = OTPInfo(code: code, sender: sender, date: date) 492 | break 493 | } 494 | } 495 | } 496 | 497 | if newestIdInDb > lastCheckedMessageID { 498 | lastCheckedMessageID = newestIdInDb 499 | } 500 | 501 | if let info = otpInfoFound { 502 | // Add to history if not duplicate 503 | if !otpHistory.contains(where: { $0.code == info.code && $0.date == info.date }) { 504 | otpHistory.insert(info, at: 0) 505 | 506 | // Trim history to max size 507 | let maxSize = PreferencesManager.shared.maxHistorySize 508 | if otpHistory.count > maxSize { 509 | otpHistory.removeLast() 510 | } 511 | } 512 | 513 | // Copy to clipboard 514 | ClipboardManager.shared.copy( 515 | text: info.code, 516 | autoClear: PreferencesManager.shared.autoClipboardClear 517 | ) 518 | 519 | // Visual feedback 520 | animateStatusItemSuccess() 521 | 522 | // Audio feedback 523 | if PreferencesManager.shared.soundEnabled { 524 | playSound() 525 | } 526 | 527 | // Send notification 528 | if PreferencesManager.shared.notificationsEnabled { 529 | sendNotification(for: info) 530 | } 531 | 532 | onUpdate?() 533 | return true 534 | } 535 | } catch { 536 | logger.error("Database query failed: \(error.localizedDescription)") 537 | animateStatusItemFailure() 538 | } 539 | 540 | return false 541 | } 542 | 543 | // MARK: - OTP Extraction 544 | 545 | /// Extracts OTP code from a message text using a two-step keyword + regex approach 546 | /// - Parameter text: The message text to analyze 547 | /// - Returns: The extracted OTP code, or `nil` if no code was found 548 | private func extractOTP(from text: String) -> String? { 549 | // Step 1: Check for OTP-related keywords to quickly filter irrelevant messages 550 | // This includes English, Hebrew, and common OTP patterns 551 | let keywords = [ 552 | "code", "verification", "OTP", "2FA", "token", "PIN", "verify", "authentication", "confirm", 553 | "קוד", "סיסמה", "סיסמתך", "אימות", // Hebrew keywords including "your password" 554 | "G-" // Google verification codes 555 | ] 556 | 557 | let keywordPattern = "(?i)(" + keywords.joined(separator: "|") + ")" 558 | 559 | guard text.range(of: keywordPattern, options: .regularExpression) != nil else { 560 | // No keywords found, so it's not an OTP message 561 | return nil 562 | } 563 | 564 | // Step 2: Try to find a numeric OTP code (4-9 digits) 565 | if let code = extractWithRegex(digitOTPRegex, from: text) { 566 | return code 567 | } 568 | 569 | // Step 3: Try to find an alphanumeric OTP code (6-8 characters) 570 | if let code = extractWithRegex(alphanumericOTPRegex, from: text) { 571 | // Additional validation: must contain at least one digit 572 | if code.contains(where: { $0.isNumber }) { 573 | return code 574 | } 575 | } 576 | 577 | return nil 578 | } 579 | 580 | /// Helper method to extract code using a pre-compiled regex 581 | /// - Parameters: 582 | /// - regex: The compiled NSRegularExpression to use 583 | /// - text: The text to search in 584 | /// - Returns: The first matched code, or `nil` if no match found 585 | private func extractWithRegex(_ regex: NSRegularExpression, from text: String) -> String? { 586 | let range = NSRange(text.startIndex..