├── .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..