diff --git a/mac/Info.plist b/mac/Info.plist
new file mode 100644
index 0000000..496a3f9
--- /dev/null
+++ b/mac/Info.plist
@@ -0,0 +1,41 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Soniox Voice
+ CFBundleExecutable
+ SonioxVoice
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ com.soniox.voice
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ SonioxVoice
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSMicrophoneUsageDescription
+ Soniox Voice به دسترسی میکروفون جهت ضبط صدا و تبدیل آن به متن نیاز دارد.
+ NSAccessibilityUsageDescription
+ Soniox Voice به دسترسی Accessibility جهت درج خودکار متن در برنامه فعال نیاز دارد.
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+
+
diff --git a/mac/make_icon.py b/mac/make_icon.py
new file mode 100644
index 0000000..7b012d7
--- /dev/null
+++ b/mac/make_icon.py
@@ -0,0 +1,121 @@
+import sys
+import os
+import math
+from PIL import Image, ImageDraw, ImageFilter
+
+def create_app_icon(output_dir):
+ os.makedirs(output_dir, exist_ok=True)
+ iconset_dir = os.path.join(output_dir, "AppIcon.iconset")
+ os.makedirs(iconset_dir, exist_ok=True)
+
+ size = 1024
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(img)
+
+ # Background Squircle / Rounded rect with gradient
+ margin = 80
+ rect = [margin, margin, size - margin, size - margin]
+ radius = 200
+
+ # Create base squircle mask
+ mask = Image.new("L", (size, size), 0)
+ mask_draw = ImageDraw.Draw(mask)
+ mask_draw.rounded_rectangle(rect, radius=radius, fill=255)
+
+ # Render gradient
+ grad = Image.new("RGBA", (size, size))
+ grad_draw = ImageDraw.Draw(grad)
+
+ # Rich Purple / Blue / Cyber Teal gradient
+ for y in range(size):
+ ratio = y / size
+ r = int(79 + (124 - 79) * ratio)
+ g = int(70 + (58 - 70) * ratio)
+ b = int(229 + (237 - 229) * ratio)
+ grad_draw.line([(0, y), (size, y)], fill=(r, g, b, 255))
+
+ img.paste(grad, (0, 0), mask)
+
+ # Draw Inner Glowing Waveform & Microphone
+ draw = ImageDraw.Draw(img)
+
+ # Mic Capsule
+ center_x = size // 2
+ center_y = size // 2 - 40
+ mic_w = 120
+ mic_h = 240
+
+ # Glow behind mic
+ glow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ glow_draw = ImageDraw.Draw(glow)
+ glow_draw.rounded_rectangle([center_x - mic_w//2 - 20, center_y - mic_h//2 - 20, center_x + mic_w//2 + 20, center_y + mic_h//2 + 20], radius=80, fill=(255, 255, 255, 60))
+ glow = glow.filter(ImageFilter.GaussianBlur(30))
+ img.alpha_composite(glow)
+
+ draw = ImageDraw.Draw(img)
+
+ # Mic Body (Capsule)
+ draw.rounded_rectangle(
+ [center_x - mic_w//2, center_y - mic_h//2, center_x + mic_w//2, center_y + mic_h//2],
+ radius=60,
+ fill=(255, 255, 255, 250)
+ )
+
+ # Mic Arc / Cradle
+ cradle_w = 220
+ cradle_h = 200
+ arc_top = center_y - 20
+ draw.arc(
+ [center_x - cradle_w//2, arc_top, center_x + cradle_w//2, arc_top + cradle_h],
+ start=0,
+ end=180,
+ fill=(255, 255, 255, 240),
+ width=24
+ )
+
+ # Mic Stand / Stem
+ stem_top = arc_top + cradle_h
+ draw.line([(center_x, stem_top), (center_x, stem_top + 70)], fill=(255, 255, 255, 240), width=24)
+ # Mic Base
+ draw.line([(center_x - 80, stem_top + 70), (center_x + 80, stem_top + 70)], fill=(255, 255, 255, 240), width=24)
+
+ # Sonic Sound Waves on sides
+ for side in [-1, 1]:
+ for i, r in enumerate([190, 260]):
+ wave_cx = center_x + side * 40
+ wave_w = r * 2
+ wave_h = r * 2
+ start_ang = 300 if side == 1 else 120
+ end_ang = 60 if side == 1 else 240
+ draw.arc(
+ [wave_cx - wave_w//2, center_y - wave_h//2, wave_cx + wave_w//2, center_y + wave_h//2],
+ start=start_ang,
+ end=end_ang,
+ fill=(255, 255, 255, 180 - i * 60),
+ width=18
+ )
+
+ # Save standard sizes
+ sizes = [
+ (16, "icon_16x16.png"),
+ (32, "icon_16x16@2x.png"),
+ (32, "icon_32x32.png"),
+ (64, "icon_32x32@2x.png"),
+ (128, "icon_128x128.png"),
+ (256, "icon_128x128@2x.png"),
+ (256, "icon_256x256.png"),
+ (512, "icon_256x256@2x.png"),
+ (512, "icon_512x512.png"),
+ (1024, "icon_512x512@2x.png"),
+ ]
+
+ for s, name in sizes:
+ resized = img.resize((s, s), Image.Resampling.LANCZOS)
+ resized.save(os.path.join(iconset_dir, name))
+
+ master_path = os.path.join(output_dir, "AppIcon.png")
+ img.save(master_path)
+ print("Iconset generated in", iconset_dir)
+
+if __name__ == "__main__":
+ create_app_icon("/tmp/soniox_build/resources")
diff --git a/mac/package.sh b/mac/package.sh
new file mode 100755
index 0000000..af1f44b
--- /dev/null
+++ b/mac/package.sh
@@ -0,0 +1,54 @@
+#!/bin/zsh
+set -e
+
+PROJECT_DIR="/Users/alig/Develop/SonioxVoice"
+BUILD_DIR="$PROJECT_DIR/build"
+APP_NAME="Soniox Voice"
+APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
+DMG_NAME="SonioxVoice-v1.0.0.dmg"
+
+echo "🔨 Building $APP_NAME..."
+mkdir -p "$BUILD_DIR"
+rm -rf "$APP_BUNDLE"
+
+# 1. Compile Swift sources
+swiftc -O -target arm64-apple-macosx13.0 \
+ -framework Cocoa -framework AVFoundation -framework Carbon \
+ "$PROJECT_DIR"/src/*.swift \
+ -o "$BUILD_DIR/SonioxVoice"
+
+# 2. Assemble .app bundle
+mkdir -p "$APP_BUNDLE/Contents/MacOS"
+mkdir -p "$APP_BUNDLE/Contents/Resources"
+
+cp "$BUILD_DIR/SonioxVoice" "$APP_BUNDLE/Contents/MacOS/SonioxVoice"
+cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
+cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
+
+# 3. Stable Codesign with explicit identifier
+codesign --force --deep --sign - --identifier "com.soniox.voice" "$APP_BUNDLE"
+
+echo "✅ App bundle assembled at $APP_BUNDLE"
+
+# 4. Create DMG Installer
+echo "📦 Creating DMG Installer..."
+DMG_STAGING="$BUILD_DIR/dmg_staging"
+rm -rf "$DMG_STAGING" "$BUILD_DIR/$DMG_NAME"
+mkdir -p "$DMG_STAGING"
+
+cp -R "$APP_BUNDLE" "$DMG_STAGING/"
+ln -s /Applications "$DMG_STAGING/Applications"
+
+hdiutil create -volname "Soniox Voice" -srcfolder "$DMG_STAGING" -ov -format UDZO "$BUILD_DIR/$DMG_NAME"
+echo "✅ DMG created at $BUILD_DIR/$DMG_NAME"
+
+# 5. Create ZIP Archive
+cd "$BUILD_DIR"
+zip -r -y "SonioxVoice-v1.0.0.zip" "$APP_NAME.app"
+
+# 6. Install to /Applications on Mac
+echo "🚀 Installing to /Applications/$APP_NAME.app..."
+rm -rf "/Applications/$APP_NAME.app"
+cp -R "$APP_BUNDLE" "/Applications/$APP_NAME.app"
+
+echo "🎉 All Done Successfully!"
diff --git a/mac/resources/AppIcon.icns b/mac/resources/AppIcon.icns
new file mode 100644
index 0000000..76f3aa8
Binary files /dev/null and b/mac/resources/AppIcon.icns differ
diff --git a/mac/resources/AppIcon.iconset/icon_128x128.png b/mac/resources/AppIcon.iconset/icon_128x128.png
new file mode 100644
index 0000000..27dbadb
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_128x128.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_128x128@2x.png b/mac/resources/AppIcon.iconset/icon_128x128@2x.png
new file mode 100644
index 0000000..6d1fa4d
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_128x128@2x.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_16x16.png b/mac/resources/AppIcon.iconset/icon_16x16.png
new file mode 100644
index 0000000..aca1b51
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_16x16.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_16x16@2x.png b/mac/resources/AppIcon.iconset/icon_16x16@2x.png
new file mode 100644
index 0000000..a542351
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_16x16@2x.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_256x256.png b/mac/resources/AppIcon.iconset/icon_256x256.png
new file mode 100644
index 0000000..6d1fa4d
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_256x256.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_256x256@2x.png b/mac/resources/AppIcon.iconset/icon_256x256@2x.png
new file mode 100644
index 0000000..2fa55f0
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_256x256@2x.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_32x32.png b/mac/resources/AppIcon.iconset/icon_32x32.png
new file mode 100644
index 0000000..a542351
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_32x32.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_32x32@2x.png b/mac/resources/AppIcon.iconset/icon_32x32@2x.png
new file mode 100644
index 0000000..bee1a97
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_32x32@2x.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_512x512.png b/mac/resources/AppIcon.iconset/icon_512x512.png
new file mode 100644
index 0000000..2fa55f0
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_512x512.png differ
diff --git a/mac/resources/AppIcon.iconset/icon_512x512@2x.png b/mac/resources/AppIcon.iconset/icon_512x512@2x.png
new file mode 100644
index 0000000..7c5030a
Binary files /dev/null and b/mac/resources/AppIcon.iconset/icon_512x512@2x.png differ
diff --git a/mac/resources/AppIcon.png b/mac/resources/AppIcon.png
new file mode 100644
index 0000000..7c5030a
Binary files /dev/null and b/mac/resources/AppIcon.png differ
diff --git a/mac/src/AppDelegate.swift b/mac/src/AppDelegate.swift
new file mode 100644
index 0000000..757411a
--- /dev/null
+++ b/mac/src/AppDelegate.swift
@@ -0,0 +1,242 @@
+import Cocoa
+import AVFoundation
+import ApplicationServices
+import Network
+
+public final class AppDelegate: NSObject, NSApplicationDelegate {
+ private var statusBarController: StatusBarController!
+ private var audioRecorder = AudioRecorder()
+ private var activeSession: SonioxLiveSession?
+ private var isBusyFinalizing = false
+ private var currentAudioLevel: Float = 0.0
+ private var latestPartialText: String? = nil
+ private var permissionPollTimer: Timer?
+
+ // Remote Android Phone Local Receiver (Port 8999)
+ private var remoteListener: NWListener?
+
+ public func applicationDidFinishLaunching(_ notification: Notification) {
+ // Completely silent operation by default
+ UserDefaults.standard.set(false, forKey: "SonioxPlaySounds")
+
+ statusBarController = StatusBarController()
+ statusBarController.onToggleRecording = { [weak self] in
+ self?.toggleRecording()
+ }
+
+ // Push-To-Talk / Toggle Hotkey Setup
+ HotkeyManager.shared.onHotkeyPressed = { [weak self] in
+ guard let self = self else { return }
+ if HotkeyManager.shared.currentMode == .pushToTalk {
+ if !self.audioRecorder.isRecording {
+ self.startRecording()
+ }
+ } else {
+ self.toggleRecording()
+ }
+ }
+
+ HotkeyManager.shared.onHotkeyReleased = { [weak self] in
+ guard let self = self else { return }
+ if HotkeyManager.shared.currentMode == .pushToTalk {
+ if self.audioRecorder.isRecording {
+ self.stopRecordingAndTranscribe()
+ }
+ }
+ }
+
+ audioRecorder.onAudioLevelUpdate = { [weak self] level in
+ guard let self = self else { return }
+ self.currentAudioLevel = level
+ if self.audioRecorder.isRecording {
+ HUDOverlayController.shared.show(state: .recording(level: level, liveText: self.latestPartialText))
+ }
+ }
+
+ audioRecorder.onAudioChunkAvailable = { [weak self] chunk in
+ self?.activeSession?.sendAudioChunk(chunk)
+ }
+
+ SonioxSessionPool.shared.prewarmNextSession()
+ startRemotePasteServer()
+
+ // Connect to Linux Persistent Gateway (via local tunnel 18999 -> 8999)
+ RelayClient.shared.onRemoteUpdateReceived = { text, cursor, isFullReplace in
+ print("AppDelegate: 📥 Clean Remote Input Update: '\(text.prefix(30))...' (replace: \(isFullReplace))")
+
+ // 1. FIRST apply the remote update directly into the active input while window is in pristine focus!
+ FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: cursor, isFullReplace: isFullReplace)
+
+ // 2. THEN show the floating success HUD notification
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+ HUDOverlayController.shared.show(state: .success(text: text))
+ }
+ }
+ RelayClient.shared.start()
+
+ HotkeyManager.shared.registerHotkeys()
+ checkInitialPermissions()
+ }
+
+ private func startRemotePasteServer() {
+ do {
+ let port: NWEndpoint.Port = 8999
+ let listener = try NWListener(using: .tcp, on: port)
+
+ listener.newConnectionHandler = { [weak self] connection in
+ guard let self = self else { return }
+ connection.start(queue: .main)
+ self.handleRemoteConnection(connection)
+ }
+
+ listener.start(queue: .main)
+ self.remoteListener = listener
+ print("RemotePasteServer: Listening on 0.0.0.0:8999")
+ } catch {
+ print("RemotePasteServer: Failed to bind port 8999:", error)
+ }
+ }
+
+ private func handleRemoteConnection(_ connection: NWConnection) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, _ in
+ guard let data = data, let reqStr = String(data: data, encoding: .utf8) else {
+ connection.cancel()
+ return
+ }
+
+ if reqStr.contains("GET /health") || reqStr.contains("GET /status") {
+ let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"
+ connection.send(content: response.data(using: .utf8), completion: .contentProcessed({ _ in
+ connection.cancel()
+ }))
+ return
+ }
+
+ if reqStr.contains("POST /paste") {
+ if let bodyRange = reqStr.range(of: "\r\n\r\n") {
+ let bodyJsonStr = String(reqStr[bodyRange.upperBound...])
+ if let bodyData = bodyJsonStr.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any],
+ let text = json["text"] as? String {
+
+ DispatchQueue.main.async {
+ FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: nil, isFullReplace: true)
+ }
+ }
+ }
+
+ let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 19\r\n\r\n{\"status\":\"pasted\"}"
+ connection.send(content: response.data(using: .utf8), completion: .contentProcessed({ _ in
+ connection.cancel()
+ }))
+ return
+ }
+
+ connection.cancel()
+ }
+ }
+
+ private func checkInitialPermissions() {
+ // 1. Force macOS to trigger the system Accessibility prompt dialog!
+ let axOptions = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
+ let axTrusted = AXIsProcessTrustedWithOptions(axOptions)
+ print("AppDelegate: 🔒 Accessibility Permission Status (trusted: \(axTrusted))")
+
+ if !axTrusted {
+ print("AppDelegate: ⚠️ Accessibility not yet granted! Prompting user and monitoring...")
+ // Open Accessibility pane directly on both modern and legacy macOS
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ if let url1 = URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility") {
+ NSWorkspace.shared.open(url1)
+ }
+ if let url2 = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
+ NSWorkspace.shared.open(url2)
+ }
+ }
+
+ // Poll every 1.0 seconds until user toggles the switch
+ permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
+ let nowTrusted = AXIsProcessTrusted()
+ if nowTrusted {
+ print("AppDelegate: 🟢 Accessibility Permission GRANTED by user!")
+ timer.invalidate()
+ self?.permissionPollTimer = nil
+ HotkeyManager.shared.registerHotkeys()
+ HUDOverlayController.shared.show(state: .success(text: "دسترسی Accessibility تایید شد ✅"))
+ }
+ }
+ }
+
+ // 2. Microphone permission
+ audioRecorder.requestMicrophonePermission { granted in
+ if !granted {
+ print("Warning: Microphone permission not granted.")
+ }
+ }
+ }
+
+ public func toggleRecording() {
+ if audioRecorder.isRecording {
+ stopRecordingAndTranscribe()
+ } else {
+ startRecording()
+ }
+ }
+
+ public func startRecording() {
+ guard !audioRecorder.isRecording, !isBusyFinalizing else { return }
+
+ latestPartialText = nil
+
+ let session = SonioxSessionPool.shared.acquireSession()
+ self.activeSession = session
+
+ session.onPartialText = { [weak self] liveText in
+ guard let self = self, self.audioRecorder.isRecording else { return }
+ self.latestPartialText = liveText
+ HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: liveText))
+ }
+
+ session.onFinalResult = { [weak self] result in
+ guard let self = self else { return }
+ self.isBusyFinalizing = false
+ self.activeSession = nil
+ SonioxSessionPool.shared.prewarmNextSession()
+
+ switch result {
+ case .success(let text):
+ FocusedInputSync.shared.applyRemoteUpdate(text: text, cursor: nil, isFullReplace: false)
+ case .failure(let error):
+ print("Soniox error:", error)
+ HUDOverlayController.shared.show(state: .error(message: error.localizedDescription))
+ }
+ }
+
+ do {
+ try audioRecorder.startRecording()
+ statusBarController.updateIcon(state: .recording)
+ statusBarController.buildMenu(isRecording: true)
+ HUDOverlayController.shared.show(state: .recording(level: 0.0, liveText: nil))
+ } catch {
+ HUDOverlayController.shared.show(state: .error(message: error.localizedDescription))
+ }
+ }
+
+ public func stopRecordingAndTranscribe() {
+ guard audioRecorder.isRecording, !isBusyFinalizing else { return }
+ isBusyFinalizing = true
+
+ _ = audioRecorder.stopRecording()
+ statusBarController.updateIcon(state: .transcribing)
+ statusBarController.buildMenu(isRecording: false)
+ HUDOverlayController.shared.show(state: .transcribing)
+
+ guard let session = self.activeSession else {
+ self.isBusyFinalizing = false
+ HUDOverlayController.shared.hide(animated: true)
+ return
+ }
+
+ session.finalizeStream()
+ }
+}
diff --git a/mac/src/AudioRecorder.swift b/mac/src/AudioRecorder.swift
new file mode 100644
index 0000000..d712c65
--- /dev/null
+++ b/mac/src/AudioRecorder.swift
@@ -0,0 +1,165 @@
+import Foundation
+import AVFoundation
+import CoreMedia
+
+public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
+ private var captureSession: AVCaptureSession?
+ private var audioOutput: AVCaptureAudioDataOutput?
+ private var audioConverter: AVAudioConverter?
+ private let targetFormat: AVAudioFormat
+
+ private var pcmBuffer = Data()
+ private let lock = NSLock()
+ private let captureQueue = DispatchQueue(label: "com.soniox.audiocapture", qos: .userInteractive)
+
+ public private(set) var isRecording = false
+ public var onAudioLevelUpdate: ((Float) -> Void)?
+ public var onAudioChunkAvailable: ((Data) -> Void)?
+
+ public override init() {
+ self.targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false)!
+ super.init()
+ }
+
+ public func requestMicrophonePermission(completion: @escaping (Bool) -> Void) {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized:
+ completion(true)
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
+ DispatchQueue.main.async {
+ completion(granted)
+ }
+ }
+ case .denied, .restricted:
+ completion(false)
+ @unknown default:
+ completion(false)
+ }
+ }
+
+ public func startRecording() throws {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if isRecording { return }
+ pcmBuffer.removeAll()
+
+ guard let device = AVCaptureDevice.default(for: .audio) else {
+ throw NSError(domain: "AudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "میکروفونی یافت نشد"])
+ }
+
+ let session = AVCaptureSession()
+ let input = try AVCaptureDeviceInput(device: device)
+
+ if session.canAddInput(input) {
+ session.addInput(input)
+ }
+
+ let output = AVCaptureAudioDataOutput()
+ output.setSampleBufferDelegate(self, queue: captureQueue)
+
+ if session.canAddOutput(output) {
+ session.addOutput(output)
+ }
+
+ self.captureSession = session
+ self.audioOutput = output
+
+ session.startRunning()
+ isRecording = true
+ print("AudioRecorder: Started recording with device:", device.localizedName)
+ }
+
+ public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+ guard isRecording else { return }
+
+ guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
+ let srcFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc)
+
+ if audioConverter == nil || audioConverter?.inputFormat != srcFormat {
+ audioConverter = AVAudioConverter(from: srcFormat, to: targetFormat)
+ }
+ guard let converter = self.audioConverter else { return }
+
+ guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
+ let numSamples = CMSampleBufferGetNumSamples(sampleBuffer)
+ guard numSamples > 0 else { return }
+
+ guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: AVAudioFrameCount(numSamples)) else { return }
+ srcBuffer.frameLength = AVAudioFrameCount(numSamples)
+
+ var lengthAtOffset = 0
+ var totalLength = 0
+ var dataPointer: UnsafeMutablePointer?
+
+ if CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: &lengthAtOffset, totalLengthOut: &totalLength, dataPointerOut: &dataPointer) == noErr,
+ let dataPtr = dataPointer {
+ if let floatData = srcBuffer.floatChannelData?[0] {
+ memcpy(floatData, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 4))
+ } else if let int16Data = srcBuffer.int16ChannelData?[0] {
+ memcpy(int16Data, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 2))
+ }
+ }
+
+ let outCapacity = AVAudioFrameCount(Double(numSamples) * (16000.0 / srcFormat.sampleRate)) + 128
+ guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else { return }
+
+ var error: NSError?
+ var haveData = true
+ let status = converter.convert(to: outBuffer, error: &error) { inNumPackets, outStatus in
+ if haveData {
+ haveData = false
+ outStatus.pointee = .haveData
+ return srcBuffer
+ } else {
+ outStatus.pointee = .noDataNow
+ return nil
+ }
+ }
+
+ if status == .haveData || status == .inputRanDry {
+ let frameLen = Int(outBuffer.frameLength)
+ if frameLen > 0, let int16Ptr = outBuffer.int16ChannelData?[0] {
+ let bytesCount = frameLen * MemoryLayout.size
+ let data = Data(bytes: int16Ptr, count: bytesCount)
+
+ lock.lock()
+ pcmBuffer.append(data)
+ lock.unlock()
+
+ // Stream live audio chunk to WebSocket immediately
+ onAudioChunkAvailable?(data)
+
+ // Calculate RMS level for HUD
+ var sumSquare: Float = 0
+ for i in 0.. Data {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if !isRecording { return pcmBuffer }
+ isRecording = false
+
+ captureSession?.stopRunning()
+ captureSession = nil
+ audioOutput = nil
+ audioConverter = nil
+
+ print("AudioRecorder: Stopped. Total PCM captured: \(pcmBuffer.count) bytes (\(Double(pcmBuffer.count)/32000.0) seconds)")
+ return pcmBuffer
+ }
+}
diff --git a/mac/src/FocusedInputSync.swift b/mac/src/FocusedInputSync.swift
new file mode 100644
index 0000000..312b1fe
--- /dev/null
+++ b/mac/src/FocusedInputSync.swift
@@ -0,0 +1,380 @@
+import Cocoa
+import ApplicationServices
+import CoreGraphics
+
+public struct MacInputState: Codable {
+ public let source: String
+ public let app: String
+ public let text: String
+ public let cursor: Int
+ public let selection: Int
+ public let revision: Int64
+ public let timestamp: Double
+
+ public init(app: String, text: String, cursor: Int, selection: Int, revision: Int64) {
+ self.source = "mac"
+ self.app = app
+ self.text = text
+ self.cursor = cursor
+ self.selection = selection
+ self.revision = revision
+ self.timestamp = Date().timeIntervalSince1970
+ }
+}
+
+public final class FocusedInputSync {
+ public static let shared = FocusedInputSync()
+
+ private let systemWideElement: AXUIElement
+ private var isApplyingRemoteChange: Bool = false
+ private var lastObservedText: String = ""
+ private var lastObservedCursor: Int = -1
+ private var lastObservedApp: String = ""
+ private var localRevision: Int64 = 0
+ private var remoteChangeExpiryTime: Double = 0
+
+ private init() {
+ self.systemWideElement = AXUIElementCreateSystemWide()
+ enableGlobalAccessibility()
+ }
+
+ private func enableGlobalAccessibility() {
+ AXUIElementSetAttributeValue(systemWideElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(systemWideElement, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+ }
+
+ /// Resolves the actual user-facing application, bypassing system overlays
+ public func getRealFrontmostApp() -> NSRunningApplication? {
+ if let front = NSWorkspace.shared.frontmostApplication,
+ front.activationPolicy == .regular,
+ let bundleId = front.bundleIdentifier,
+ !bundleId.contains("notificationcenter"),
+ !bundleId.contains("controlcenter"),
+ !bundleId.contains("WindowManager"),
+ !bundleId.contains("Soniox") {
+ return front
+ }
+
+ let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
+ for win in windowList {
+ let layer = win[kCGWindowLayer as String] as? Int ?? -1
+ let pid = win[kCGWindowOwnerPID as String] as? pid_t ?? 0
+ let bounds = win[kCGWindowBounds as String] as? [String: Any] ?? [:]
+ let width = bounds["Width"] as? CGFloat ?? 0
+ let height = bounds["Height"] as? CGFloat ?? 0
+
+ if layer == 0 && width > 100 && height > 100 {
+ if let app = NSRunningApplication(processIdentifier: pid),
+ app.activationPolicy == .regular,
+ let bundleId = app.bundleIdentifier,
+ !bundleId.contains("notificationcenter"),
+ !bundleId.contains("controlcenter"),
+ !bundleId.contains("WindowManager"),
+ !bundleId.contains("Soniox") {
+ return app
+ }
+ }
+ }
+ return NSWorkspace.shared.frontmostApplication
+ }
+
+ private func findFocusedDescendant(_ elem: AXUIElement, depth: Int = 0) -> AXUIElement? {
+ if depth > 12 { return nil }
+
+ var isFocusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &isFocusedObj) == .success,
+ let isFocused = isFocusedObj as? Bool, isFocused {
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+ if role != "AXWindow" && role != "AXApplication" && role != "AXGroup" && role != "AXSplitGroup" && role != "AXScrollArea" {
+ return elem
+ }
+ }
+
+ var childrenObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success,
+ let children = childrenObj as? [AXUIElement] {
+ for child in children {
+ if let found = findFocusedDescendant(child, depth: depth + 1) {
+ return found
+ }
+ }
+ }
+ return nil
+ }
+
+ public func getFocusedElement() -> (AXUIElement?, String) {
+ guard let frontApp = getRealFrontmostApp() else { return (nil, "App") }
+ let appName = frontApp.localizedName ?? "App"
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+
+ AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ var targetElem: AXUIElement?
+
+ // 1. System-wide focused element
+ var focusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
+ let obj = focusedObj {
+ let elem = obj as! AXUIElement
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+ if role != "AXWindow" && role != "AXApplication" {
+ targetElem = elem
+ }
+ }
+
+ // 2. App focused element
+ if targetElem == nil {
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
+ let obj = focusedObj {
+ let elem = obj as! AXUIElement
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+ if role != "AXWindow" && role != "AXApplication" {
+ targetElem = elem
+ }
+ }
+ }
+
+ // 3. App focused window element
+ if targetElem == nil {
+ var focusedWinObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &focusedWinObj) == .success,
+ let win = focusedWinObj {
+ var winFocObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue((win as! AXUIElement), kAXFocusedUIElementAttribute as CFString, &winFocObj) == .success,
+ let obj = winFocObj {
+ targetElem = (obj as! AXUIElement)
+ }
+ }
+ }
+
+ // 4. Recursive search in tree
+ if targetElem == nil {
+ targetElem = findFocusedDescendant(appElem)
+ }
+
+ return (targetElem, appName)
+ }
+
+ /// Inspects current focused element on Mac and returns state snapshot if user typed on Mac
+ public func inspectCurrentState() -> MacInputState? {
+ let now = Date().timeIntervalSince1970
+ if isApplyingRemoteChange || now < remoteChangeExpiryTime {
+ return nil
+ }
+
+ let (elemOpt, appName) = getFocusedElement()
+ let appChanged = (appName != lastObservedApp)
+ if appChanged {
+ lastObservedApp = appName
+ lastObservedText = ""
+ lastObservedCursor = 0
+ }
+
+ guard let elem = elemOpt else {
+ if appChanged {
+ localRevision += 1
+ return MacInputState(app: appName, text: "", cursor: 0, selection: 0, revision: localRevision)
+ }
+ return nil
+ }
+
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = roleObj as? String ?? ""
+ let isTextRole = (role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXSearchField")
+
+ var text = ""
+ var valObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success,
+ let val = valObj {
+ if let str = val as? String {
+ text = str
+ } else if let attrStr = val as? NSAttributedString {
+ text = attrStr.string
+ }
+ }
+
+ if text.isEmpty && isTextRole {
+ var countObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countObj) == .success,
+ let count = countObj as? Int, count > 0 {
+ var range = CFRange(location: 0, length: count)
+ if let axRange = AXValueCreate(.cfRange, &range) {
+ var strObj: CFTypeRef?
+ if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &strObj) == .success,
+ let str = strObj as? String {
+ text = str
+ }
+ }
+ }
+ }
+
+ var cursor = text.count
+ var selLen = 0
+ var rangeObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) == .success,
+ let axRange = rangeObj {
+ var range = CFRange()
+ if AXValueGetValue(axRange as! AXValue, .cfRange, &range) {
+ cursor = range.location
+ selLen = range.length
+ }
+ }
+
+ if text == lastObservedText && cursor == lastObservedCursor && !appChanged {
+ return nil
+ }
+
+ lastObservedText = text
+ lastObservedCursor = cursor
+ lastObservedApp = appName
+ localRevision += 1
+
+ return MacInputState(app: appName, text: text, cursor: cursor, selection: selLen, revision: localRevision)
+ }
+
+ /// Perfectly mirrors the full text to the active Mac input box in real-time
+ @discardableResult
+ public func applyRemoteUpdate(text: String, cursor: Int? = nil, isFullReplace: Bool = true) -> Bool {
+ isApplyingRemoteChange = true
+ remoteChangeExpiryTime = Date().timeIntervalSince1970 + 0.8
+
+ defer {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
+ self.isApplyingRemoteChange = false
+ }
+ }
+
+ // Normalize line endings and preserve intentional multiline text and newlines
+ let cleanText = text.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
+
+ let targetCursor = cursor ?? cleanText.count
+ lastObservedText = cleanText
+ lastObservedCursor = targetCursor
+
+ let (_, appName) = getFocusedElement()
+ lastObservedApp = appName
+
+ // Universal Quartz CGEvent Keystroke Engine (Cmd+A -> Cmd+V / Backspace or pure Cmd+V)
+ // Works 100% reliably across native, web, and Electron/Chromium apps (e.g. Antigravity, VS Code, Slack, Firefox)
+ print("FocusedInputSync: 🚀 Injecting text into '\(appName)' (replace: \(isFullReplace), len: \(cleanText.count), lines: \(cleanText.components(separatedBy: "\n").count))")
+ if isFullReplace {
+ return executeCleanFullReplace(cleanText)
+ } else {
+ return pasteOnlyViaCleanKeystroke(cleanText)
+ }
+ }
+
+ /**
+ * Inserts speech directly at active cursor via clean Cmd+V
+ */
+ private func pasteOnlyViaCleanKeystroke(_ text: String) -> Bool {
+ guard !text.isEmpty else { return true }
+
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(text, forType: .string)
+
+ let src = CGEventSource(stateID: .hidSystemState)
+ let kVK_ANSI_V: CGKeyCode = 9
+
+ // Paste: Cmd + V
+ if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) {
+ vDown.flags = .maskCommand
+ vDown.post(tap: .cghidEventTap)
+ vDown.post(tap: .cgSessionEventTap)
+ }
+ usleep(8000)
+
+ if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) {
+ vUp.flags = .maskCommand
+ vUp.post(tap: .cghidEventTap)
+ vUp.post(tap: .cgSessionEventTap)
+ }
+ usleep(10000)
+ return true
+ }
+
+ /**
+ * Replaces the entire content of active input box cleanly in real-time.
+ * If text is empty: Cmd+A -> Backspace.
+ * If text is non-empty: Cmd+A -> Cmd+V.
+ */
+ private func executeCleanFullReplace(_ text: String) -> Bool {
+ let src = CGEventSource(stateID: .hidSystemState)
+ let kVK_ANSI_A: CGKeyCode = 0
+ let kVK_ANSI_V: CGKeyCode = 9
+ let kVK_Delete: CGKeyCode = 51
+
+ if text.isEmpty {
+ // Select all: Cmd + A
+ if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
+ aDown.flags = .maskCommand
+ aDown.post(tap: .cghidEventTap)
+ aDown.post(tap: .cgSessionEventTap)
+ }
+ usleep(8000)
+ if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
+ aUp.flags = .maskCommand
+ aUp.post(tap: .cghidEventTap)
+ aUp.post(tap: .cgSessionEventTap)
+ }
+ usleep(15000)
+
+ // Backspace to clear
+ if let delDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: true) {
+ delDown.post(tap: .cghidEventTap)
+ delDown.post(tap: .cgSessionEventTap)
+ }
+ usleep(8000)
+ if let delUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_Delete, keyDown: false) {
+ delUp.post(tap: .cghidEventTap)
+ delUp.post(tap: .cgSessionEventTap)
+ }
+ return true
+ }
+
+ // Set clipboard
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(text, forType: .string)
+
+ // 1. Select all: Cmd + A
+ if let aDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: true) {
+ aDown.flags = .maskCommand
+ aDown.post(tap: .cghidEventTap)
+ aDown.post(tap: .cgSessionEventTap)
+ }
+ usleep(8000)
+
+ if let aUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_A, keyDown: false) {
+ aUp.flags = .maskCommand
+ aUp.post(tap: .cghidEventTap)
+ aUp.post(tap: .cgSessionEventTap)
+ }
+ usleep(10000) // 18ms for selection to settle
+
+ // 2. Paste: Cmd + V
+ if let vDown = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: true) {
+ vDown.flags = .maskCommand
+ vDown.post(tap: .cghidEventTap)
+ vDown.post(tap: .cgSessionEventTap)
+ }
+ usleep(8000)
+
+ if let vUp = CGEvent(keyboardEventSource: src, virtualKey: kVK_ANSI_V, keyDown: false) {
+ vUp.flags = .maskCommand
+ vUp.post(tap: .cghidEventTap)
+ vUp.post(tap: .cgSessionEventTap)
+ }
+
+ return true
+ }
+}
diff --git a/mac/src/HUDOverlay.swift b/mac/src/HUDOverlay.swift
new file mode 100644
index 0000000..25f69e0
--- /dev/null
+++ b/mac/src/HUDOverlay.swift
@@ -0,0 +1,24 @@
+import Cocoa
+
+public enum HUDState {
+ case hidden
+ case recording(level: Float, liveText: String? = nil)
+ case transcribing
+ case success(text: String)
+ case error(message: String)
+}
+
+/// Completely silent HUD controller (Zero-UI / Zero-toast mode)
+public final class HUDOverlayController {
+ public static let shared = HUDOverlayController()
+
+ private init() {}
+
+ public func show(state: HUDState) {
+ // Completely silent operation - no toast/panel shown
+ }
+
+ public func hide(animated: Bool = false) {
+ // No-op
+ }
+}
diff --git a/mac/src/HotkeyManager.swift b/mac/src/HotkeyManager.swift
new file mode 100644
index 0000000..b5f68a7
--- /dev/null
+++ b/mac/src/HotkeyManager.swift
@@ -0,0 +1,261 @@
+import Cocoa
+import Carbon
+import ApplicationServices
+
+public enum DictationMode: String, CaseIterable {
+ case pushToTalk = "pushToTalk" // Hold to record, release to transcribe
+ case toggle = "toggle" // Press once to start, press again to stop
+
+ public var localizedTitle: String {
+ switch self {
+ case .pushToTalk:
+ return "نگهداشتن برای صحبت (Hold to Talk)"
+ case .toggle:
+ return "فشردن برای شروع / توقف (Toggle)"
+ }
+ }
+}
+
+public enum HotkeyPreset: String, CaseIterable {
+ case option = "Option (⌥ نگهداشتن)"
+ case capsLock = "Caps Lock"
+ case controlSpace = "Control + Space"
+ case optionSpace = "Option + Space"
+ case cmdShiftSpace = "Cmd + Shift + Space"
+ case f8 = "F8"
+ case f5 = "F5"
+
+ public var keyCode: UInt32 {
+ switch self {
+ case .option:
+ return UInt32(kVK_Option) // 58
+ case .capsLock:
+ return UInt32(kVK_CapsLock) // 57
+ case .controlSpace, .optionSpace, .cmdShiftSpace:
+ return UInt32(kVK_Space)
+ case .f8:
+ return UInt32(kVK_F8)
+ case .f5:
+ return UInt32(kVK_F5)
+ }
+ }
+
+ public var carbonModifiers: UInt32 {
+ switch self {
+ case .option, .capsLock:
+ return 0
+ case .controlSpace:
+ return UInt32(controlKey)
+ case .optionSpace:
+ return UInt32(optionKey)
+ case .cmdShiftSpace:
+ return UInt32(cmdKey | shiftKey)
+ case .f8, .f5:
+ return 0
+ }
+ }
+}
+
+public final class HotkeyManager {
+ public static let shared = HotkeyManager()
+
+ public var onHotkeyPressed: (() -> Void)?
+ public var onHotkeyReleased: (() -> Void)?
+
+ private var hotKeyRef: EventHotKeyRef?
+ private var eventHandlerRef: EventHandlerRef?
+ private var eventTapPort: CFMachPort?
+ private var runLoopSource: CFRunLoopSource?
+ private var globalMonitor: Any?
+
+ private var isOptionPhysicallyDown = false
+ private var isCapsLockPhysicallyDown = false
+ private var isKeyDown = false
+
+ public var currentPreset: HotkeyPreset {
+ get {
+ let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue
+ return HotkeyPreset(rawValue: val) ?? .option
+ }
+ set {
+ UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset")
+ registerHotkeys()
+ }
+ }
+
+ public var currentMode: DictationMode {
+ get {
+ let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue
+ return DictationMode(rawValue: val) ?? .pushToTalk
+ }
+ set {
+ UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode")
+ registerHotkeys()
+ }
+ }
+
+ private init() {}
+
+ public func registerHotkeys() {
+ unregisterHotkeys()
+
+ let preset = currentPreset
+ print("Registering Hotkey for preset:", preset.rawValue, "mode:", currentMode.rawValue)
+
+ // 1. Carbon HotKey for multi-key combos (Control+Space, etc.)
+ if preset != .option && preset != .capsLock {
+ var eventTypes = [
+ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
+ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
+ ]
+
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
+ let handlerCallback: EventHandlerUPP = { (_, eventRef, userData) -> OSStatus in
+ guard let eventRef = eventRef, let userData = userData else { return noErr }
+ let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue()
+
+ let kind = GetEventKind(eventRef)
+ if kind == UInt32(kEventHotKeyPressed) {
+ DispatchQueue.main.async {
+ manager.onHotkeyPressed?()
+ }
+ } else if kind == UInt32(kEventHotKeyReleased) {
+ DispatchQueue.main.async {
+ manager.onHotkeyReleased?()
+ }
+ }
+ return noErr
+ }
+
+ InstallEventHandler(GetApplicationEventTarget(), handlerCallback, 2, &eventTypes, selfPtr, &eventHandlerRef)
+
+ let hotKeyID = EventHotKeyID(signature: OSType(0x534F4E58), id: 1)
+ RegisterEventHotKey(
+ preset.keyCode,
+ preset.carbonModifiers,
+ hotKeyID,
+ GetApplicationEventTarget(),
+ 0,
+ &hotKeyRef
+ )
+ }
+
+ // 2. Global Event Tap for single modifier keys (Option, CapsLock)
+ setupEventTap()
+
+ // 3. Secondary NSEvent Global Monitor as backup
+ setupGlobalMonitor()
+ }
+
+ private func setupEventTap() {
+ let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
+
+ let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged? in
+ guard let refcon = refcon else { return Unmanaged.passRetained(event) }
+ let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue()
+
+ let flags = event.flags.rawValue
+ let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
+
+ // 1. Option Key (Hold / Push-to-Talk)
+ if manager.currentPreset == .option {
+ let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0
+ if isAlt != manager.isOptionPhysicallyDown {
+ manager.isOptionPhysicallyDown = isAlt
+ DispatchQueue.main.async {
+ if isAlt {
+ manager.onHotkeyPressed?()
+ } else {
+ if manager.currentMode == .pushToTalk {
+ manager.onHotkeyReleased?()
+ }
+ }
+ }
+ }
+ }
+
+ // 2. CapsLock Key
+ else if manager.currentPreset == .capsLock {
+ if keyCode == 57 {
+ if !manager.isCapsLockPhysicallyDown {
+ manager.isCapsLockPhysicallyDown = true
+ DispatchQueue.main.async {
+ manager.onHotkeyPressed?()
+ }
+ } else {
+ manager.isCapsLockPhysicallyDown = false
+ if manager.currentMode == .pushToTalk {
+ DispatchQueue.main.async {
+ manager.onHotkeyReleased?()
+ }
+ }
+ }
+ return nil
+ }
+ }
+
+ return Unmanaged.passRetained(event)
+ }
+
+ if let tap = CGEvent.tapCreate(
+ tap: .cghidEventTap,
+ place: .headInsertEventTap,
+ options: .defaultTap,
+ eventsOfInterest: CGEventMask(mask),
+ callback: callback,
+ userInfo: selfPtr
+ ) {
+ self.eventTapPort = tap
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
+ self.runLoopSource = source
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
+ CGEvent.tapEnable(tap: tap, enable: true)
+ print("CGEventTap created and enabled successfully.")
+ } else {
+ print("CGEventTap creation failed. Falling back to NSEvent global monitor.")
+ }
+ }
+
+ private func setupGlobalMonitor() {
+ globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in
+ guard let self = self else { return }
+
+ // Only use as fallback if event tap is inactive
+ if self.eventTapPort == nil {
+ if self.currentPreset == .option {
+ let isAlt = event.modifierFlags.contains(.option)
+ if isAlt != self.isOptionPhysicallyDown {
+ self.isOptionPhysicallyDown = isAlt
+ if isAlt {
+ self.onHotkeyPressed?()
+ } else if self.currentMode == .pushToTalk {
+ self.onHotkeyReleased?()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public func unregisterHotkeys() {
+ if let ref = hotKeyRef {
+ UnregisterEventHotKey(ref)
+ hotKeyRef = nil
+ }
+ if let handler = eventHandlerRef {
+ RemoveEventHandler(handler)
+ eventHandlerRef = nil
+ }
+ if let tap = eventTapPort, let source = runLoopSource {
+ CGEvent.tapEnable(tap: tap, enable: false)
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
+ self.eventTapPort = nil
+ self.runLoopSource = nil
+ }
+ if let mon = globalMonitor {
+ NSEvent.removeMonitor(mon)
+ self.globalMonitor = nil
+ }
+ }
+}
diff --git a/mac/src/RelayClient.swift b/mac/src/RelayClient.swift
new file mode 100644
index 0000000..4ec3868
--- /dev/null
+++ b/mac/src/RelayClient.swift
@@ -0,0 +1,205 @@
+import Foundation
+import Cocoa
+
+public final class RelayClient: NSObject, URLSessionWebSocketDelegate {
+ public static let shared = RelayClient()
+
+ // Connects through SSH local port forward 18999 -> Linux Server 8999
+ private let primaryUrl = URL(string: "ws://127.0.0.1:18999/ws/mac")!
+
+ private var webSocketTask: URLSessionWebSocketTask?
+ private var urlSession: URLSession!
+
+ private var isRunning = false
+ public private(set) var isConnected = false
+ private var reconnectTimer: Timer?
+ private var pingTimer: Timer?
+ private var monitorTimerSource: DispatchSourceTimer?
+ private let monitorQueue = DispatchQueue(label: "com.soniox.macsync.monitor", qos: .userInteractive)
+
+ public var onRemoteUpdateReceived: ((String, Int?, Bool) -> Void)?
+
+ private var lastInjectedText: String = ""
+ private var lastInjectedTime: Double = 0
+
+ override private init() {
+ super.init()
+ let config = URLSessionConfiguration.default
+ config.waitsForConnectivity = true
+ config.timeoutIntervalForRequest = 30
+ config.timeoutIntervalForResource = 300
+ self.urlSession = URLSession(configuration: config, delegate: self, delegateQueue: .main)
+ }
+
+ public func start() {
+ guard !isRunning else { return }
+ isRunning = true
+ print("RelayClient: Starting real-time bi-directional sync engine...")
+ connect()
+ startInputMonitoring()
+ }
+
+ public func stop() {
+ isRunning = false
+ reconnectTimer?.invalidate()
+ reconnectTimer = nil
+ pingTimer?.invalidate()
+ pingTimer = nil
+ monitorTimerSource?.cancel()
+ monitorTimerSource = nil
+ webSocketTask?.cancel(with: .goingAway, reason: nil)
+ webSocketTask = nil
+ isConnected = false
+ }
+
+ private func connect() {
+ guard isRunning else { return }
+ webSocketTask?.cancel()
+
+ var request = URLRequest(url: primaryUrl)
+ request.timeoutInterval = 6
+ request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) SonioxVoice/1.0", forHTTPHeaderField: "User-Agent")
+
+ webSocketTask = urlSession.webSocketTask(with: request)
+ webSocketTask?.resume()
+
+ listenForMessages()
+ startPingTimer()
+ }
+
+ private func listenForMessages() {
+ webSocketTask?.receive { [weak self] result in
+ guard let self = self, self.isRunning else { return }
+
+ switch result {
+ case .success(let message):
+ self.isConnected = true
+ switch message {
+ case .string(let text):
+ self.handleMessageString(text)
+ case .data(let data):
+ if let text = String(data: data, encoding: .utf8) {
+ self.handleMessageString(text)
+ }
+ @unknown default:
+ break
+ }
+ self.listenForMessages()
+
+ case .failure(let error):
+ print("RelayClient: Connection error: \(error.localizedDescription)")
+ self.isConnected = false
+ self.scheduleReconnect()
+ }
+ }
+ }
+
+ private func handleMessageString(_ text: String) {
+ guard let data = text.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ return
+ }
+
+ let action = json["action"] as? String ?? json["type"] as? String ?? ""
+ let source = json["source"] as? String ?? ""
+
+ // Ignore echo messages originated by Mac itself
+ if source == "mac" { return }
+
+ let updateText = json["text"] as? String ?? json["insert_text"] as? String ?? ""
+ let cursor = json["cursor"] as? Int ?? json["cursor_pos"] as? Int
+
+ if action == "sync_state" || action == "phone_input_edit" || action == "update_input" || action == "force_replace" || action == "insert_speech" || action == "paste" {
+ let now = Date().timeIntervalSince1970
+
+ // Drop rapid duplicate transmissions within 150ms (unless force_replace)
+ if updateText == lastInjectedText && (now - lastInjectedTime) < 0.15 && action != "force_replace" {
+ return
+ }
+ lastInjectedText = updateText
+ lastInjectedTime = now
+
+ let isFullReplace = (action != "insert_speech" && action != "speech_insert")
+ print("RelayClient: ⚡ Applying phone edit: '\(updateText.prefix(30))...' (replace: \(isFullReplace), len: \(updateText.count))")
+ DispatchQueue.main.async {
+ self.onRemoteUpdateReceived?(updateText, cursor, isFullReplace)
+ }
+ }
+ }
+
+ private func startInputMonitoring() {
+ monitorTimerSource?.cancel()
+
+ let timer = DispatchSource.makeTimerSource(queue: monitorQueue)
+ // Poll every 50ms on dedicated user-interactive queue
+ timer.schedule(deadline: .now(), repeating: .milliseconds(50))
+ timer.setEventHandler { [weak self] in
+ guard let self = self, self.isRunning, self.isConnected else { return }
+ if let state = FocusedInputSync.shared.inspectCurrentState() {
+ self.sendStateToRelay(state)
+ }
+ }
+ timer.resume()
+ self.monitorTimerSource = timer
+ }
+
+ private func sendStateToRelay(_ state: MacInputState) {
+ guard let task = webSocketTask else { return }
+
+ let payload: [String: Any] = [
+ "type": "sync_state",
+ "source": "mac",
+ "app": state.app,
+ "text": state.text,
+ "cursor": state.cursor,
+ "selection": state.selection,
+ "revision": state.revision,
+ "timestamp": state.timestamp
+ ]
+
+ if let data = try? JSONSerialization.data(withJSONObject: payload),
+ let jsonStr = String(data: data, encoding: .utf8) {
+ task.send(.string(jsonStr)) { error in
+ if let error = error {
+ print("Error sending sync_state:", error)
+ }
+ }
+ }
+ }
+
+ private func startPingTimer() {
+ pingTimer?.invalidate()
+ let timer = Timer(timeInterval: 10.0, repeats: true) { [weak self] _ in
+ guard let self = self, self.isRunning else { return }
+ self.webSocketTask?.send(.string("{\"type\":\"ping\"}")) { _ in }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ self.pingTimer = timer
+ }
+
+ private func scheduleReconnect() {
+ guard isRunning else { return }
+ pingTimer?.invalidate()
+ pingTimer = nil
+
+ if reconnectTimer == nil || !(reconnectTimer?.isValid ?? false) {
+ let timer = Timer(timeInterval: 2.0, repeats: false) { [weak self] _ in
+ self?.reconnectTimer = nil
+ self?.connect()
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ self.reconnectTimer = timer
+ }
+ }
+
+ public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
+ print("RelayClient: 🟢 Persistent WebSocket Connected to Server Gateway!")
+ self.isConnected = true
+ }
+
+ public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
+ print("RelayClient: 🔴 WebSocket Closed (code: \(closeCode.rawValue))")
+ self.isConnected = false
+ self.scheduleReconnect()
+ }
+}
diff --git a/mac/src/SonioxClient.swift b/mac/src/SonioxClient.swift
new file mode 100644
index 0000000..f0451bf
--- /dev/null
+++ b/mac/src/SonioxClient.swift
@@ -0,0 +1,321 @@
+import Foundation
+
+public final class SonioxLiveSession {
+ private let primaryWsBase = "wss://translate.compare.soniox.com/compare/api/compare-websocket"
+ private let fallbackWsBase = "wss://stt.compare.soniox.com/compare/api/compare-websocket"
+ private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ private let origin = "https://translate.compare.soniox.com"
+
+ private var webSocketTask: URLSessionWebSocketTask?
+ private var urlSession: URLSession?
+ private var isFinalizing = false
+ private var isClosed = false
+ private var isConnected = false
+ private let lock = NSLock()
+
+ // Transcripts tracking
+ private var committedFinalTokens: [String] = []
+ private var currentNonFinalTokens: [String] = []
+
+ public var onPartialText: ((String) -> Void)?
+ public var onFinalResult: ((Result) -> Void)?
+ public var onConnectionStateChanged: ((Bool) -> Void)?
+
+ public var isReady: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return isConnected && !isClosed && !isFinalizing
+ }
+
+ public init(languageHints: [String] = ["fa", "en", "ar"]) {
+ let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&")
+ let urlStr = "\(primaryWsBase)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox"
+ guard let url = URL(string: urlStr) else { return }
+
+ var request = URLRequest(url: url)
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue(origin, forHTTPHeaderField: "Origin")
+ request.timeoutInterval = 20.0
+
+ let config = URLSessionConfiguration.default
+ config.waitsForConnectivity = true
+ config.requestCachePolicy = .reloadIgnoringLocalCacheData
+
+ let session = URLSession(configuration: config)
+ self.urlSession = session
+ let task = session.webSocketTask(with: request)
+ self.webSocketTask = task
+ task.resume()
+
+ // Fast ping to verify connection
+ task.sendPing { [weak self] error in
+ guard let self = self else { return }
+ self.lock.lock()
+ if error == nil && !self.isClosed {
+ self.isConnected = true
+ self.lock.unlock()
+ self.onConnectionStateChanged?(true)
+ print("SonioxLiveSession: WebSocket connected successfully.")
+ } else {
+ self.lock.unlock()
+ if let error = error {
+ print("SonioxLiveSession: Ping failed:", error)
+ }
+ }
+ }
+
+ startReceiving()
+ }
+
+ public func sendAudioChunk(_ data: Data) {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !isFinalizing, !isClosed, let task = webSocketTask else { return }
+
+ let message = URLSessionWebSocketTask.Message.data(data)
+ task.send(message) { error in
+ if let error = error {
+ print("Error streaming audio chunk:", error)
+ }
+ }
+ }
+
+ public func finalizeStream() {
+ lock.lock()
+ guard !isFinalizing, !isClosed, let task = webSocketTask else {
+ lock.unlock()
+ return
+ }
+ isFinalizing = true
+ lock.unlock()
+
+ print("SonioxLiveSession: Sending finalize packet...")
+ let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}")
+ task.send(finalizeMsg) { [weak self] error in
+ if let error = error {
+ print("Error sending finalize:", error)
+ self?.completeWithCurrentText()
+ }
+ }
+
+ // Safety timeout fallback: finalize must complete within 800ms
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.80) { [weak self] in
+ self?.completeWithCurrentText()
+ }
+ }
+
+ private func startReceiving() {
+ guard let task = webSocketTask else { return }
+ task.receive { [weak self] result in
+ guard let self = self else { return }
+
+ self.lock.lock()
+ if self.isClosed {
+ self.lock.unlock()
+ return
+ }
+ self.lock.unlock()
+
+ switch result {
+ case .success(let message):
+ var textReceived: String?
+ switch message {
+ case .string(let str):
+ textReceived = str
+ case .data(let data):
+ textReceived = String(data: data, encoding: .utf8)
+ @unknown default:
+ break
+ }
+
+ if let text = textReceived, let jsonData = text.data(using: .utf8) {
+ self.parseMessage(jsonData)
+ }
+ self.startReceiving()
+
+ case .failure(let error):
+ print("WebSocket receive status:", error)
+ self.completeWithCurrentText()
+ }
+ }
+ }
+
+ private func parseMessage(_ data: Data) {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
+
+ var gotFin = false
+ var newFinals: [String] = []
+ var newNonFinals: [String] = []
+
+ if let type = json["type"] as? String, type == "data",
+ let parts = json["parts"] as? [[String: Any]] {
+ for part in parts {
+ let transStatus = part["translation_status"] as? String
+ if transStatus == "translation" {
+ continue
+ }
+
+ let pText = part["text"] as? String ?? ""
+ let isFinal = part["is_final"] as? Bool ?? false
+
+ if pText.contains("") {
+ gotFin = true
+ let clean = pText.replacingOccurrences(of: "", with: "")
+ if !clean.isEmpty {
+ newFinals.append(clean)
+ }
+ } else if isFinal {
+ if !pText.isEmpty {
+ newFinals.append(pText)
+ }
+ } else {
+ if !pText.isEmpty {
+ newNonFinals.append(pText)
+ }
+ }
+ }
+
+ lock.lock()
+ if !newFinals.isEmpty {
+ committedFinalTokens.append(contentsOf: newFinals)
+ }
+ currentNonFinalTokens = newNonFinals
+
+ let fullCommitted = committedFinalTokens.joined()
+ let fullNonFinal = currentNonFinalTokens.joined()
+ let combined = fullCommitted + fullNonFinal
+ lock.unlock()
+
+ if !combined.isEmpty {
+ DispatchQueue.main.async { [weak self] in
+ self?.onPartialText?(combined)
+ }
+ }
+ }
+
+ let sessionEnded = json["session_ended"] as? Bool ?? false
+ let sessionDone = (json["type"] as? String) == "session_done"
+
+ if gotFin || sessionEnded || sessionDone {
+ completeWithCurrentText()
+ }
+ }
+
+ public func completeWithCurrentText() {
+ lock.lock()
+ if isClosed {
+ lock.unlock()
+ return
+ }
+ isClosed = true
+ let fullCommitted = committedFinalTokens.joined()
+ let fullNonFinal = currentNonFinalTokens.joined()
+ let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal)
+ let cleaned = sanitizeText(rawCombined)
+
+ let cb = onFinalResult
+ webSocketTask?.cancel(with: .normalClosure, reason: nil)
+ webSocketTask = nil
+ urlSession = nil
+ lock.unlock()
+
+ DispatchQueue.main.async {
+ cb?(.success(cleaned))
+ }
+ }
+
+ public func cancel() {
+ lock.lock()
+ isClosed = true
+ webSocketTask?.cancel(with: .normalClosure, reason: nil)
+ webSocketTask = nil
+ urlSession = nil
+ lock.unlock()
+ }
+
+ private func sanitizeText(_ text: String) -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return "" }
+
+ let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
+ if words.isEmpty { return "" }
+
+ let faPattern = "[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]"
+ let enPattern = "[a-zA-Z]"
+
+ func matches(_ pattern: String, in str: String) -> Bool {
+ return str.range(of: pattern, options: .regularExpression) != nil
+ }
+
+ var faCount = 0
+ var enCount = 0
+ for w in words {
+ if matches(faPattern, in: w) { faCount += 1 }
+ if matches(enPattern, in: w) { enCount += 1 }
+ }
+
+ let total = faCount + enCount
+ if total == 0 { return trimmed }
+
+ let faRatio = Double(faCount) / Double(total)
+ var cleaned: [String] = []
+
+ let stopWords: Set = ["sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"]
+
+ if faRatio >= 0.25 {
+ for w in words {
+ if matches(enPattern, in: w) && !matches(faPattern, in: w) {
+ let cleanW = w.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—"))
+ if stopWords.contains(cleanW) { continue }
+ if faRatio >= 0.70 { continue }
+ }
+ cleaned.append(w)
+ }
+ } else {
+ cleaned = words
+ }
+
+ return cleaned.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+/// Pre-warms and pools active WebSocket sessions for 0ms start latency
+public final class SonioxSessionPool {
+ public static let shared = SonioxSessionPool()
+
+ private var prewarmedSession: SonioxLiveSession?
+ private let lock = NSLock()
+
+ private init() {
+ prewarmNextSession()
+ }
+
+ public func prewarmNextSession() {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if let existing = prewarmedSession, existing.isReady {
+ return
+ }
+
+ let session = SonioxLiveSession()
+ self.prewarmedSession = session
+ }
+
+ public func acquireSession() -> SonioxLiveSession {
+ lock.lock()
+ let session = prewarmedSession
+ prewarmedSession = nil
+ lock.unlock()
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ self?.prewarmNextSession()
+ }
+
+ if let session = session, session.isReady {
+ return session
+ }
+
+ return SonioxLiveSession()
+ }
+}
diff --git a/mac/src/StatusBarController.swift b/mac/src/StatusBarController.swift
new file mode 100644
index 0000000..29457fc
--- /dev/null
+++ b/mac/src/StatusBarController.swift
@@ -0,0 +1,172 @@
+import Cocoa
+
+public final class StatusBarController {
+ private var statusItem: NSStatusItem?
+ public var onToggleRecording: (() -> Void)?
+
+ public init() {
+ setupStatusItem()
+ }
+
+ private func setupStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ updateIcon(state: .idle)
+ buildMenu()
+ }
+
+ public enum State {
+ case idle
+ case recording
+ case transcribing
+ }
+
+ public func updateIcon(state: State) {
+ guard let button = statusItem?.button else { return }
+
+ switch state {
+ case .idle:
+ if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice") {
+ image.isTemplate = true
+ button.image = image
+ }
+ button.toolTip = "Soniox Voice (آماده)"
+ case .recording:
+ if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") {
+ image.isTemplate = false
+ button.image = image
+ button.contentTintColor = NSColor.systemRed
+ }
+ button.toolTip = "در حال ضبط صدا..."
+ case .transcribing:
+ if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing") {
+ image.isTemplate = false
+ button.image = image
+ button.contentTintColor = NSColor.systemOrange
+ }
+ button.toolTip = "در حال تبدیل به متن..."
+ }
+ }
+
+ public func buildMenu(isRecording: Bool = false) {
+ let menu = NSMenu()
+ menu.autoenablesItems = false
+
+ // 1. Record Action
+ let recordTitle = isRecording ? "⏹️ توقف ضبط و درج متن" : "🎙️ شروع ضبط صدا (\(HotkeyManager.shared.currentPreset.rawValue))"
+ let recordItem = NSMenuItem(title: recordTitle, action: #selector(toggleRecordAction), keyEquivalent: "")
+ recordItem.target = self
+ menu.addItem(recordItem)
+
+ menu.addItem(NSMenuItem.separator())
+
+ // 2. Mode Submenu
+ let modeMenu = NSMenu()
+ for mode in DictationMode.allCases {
+ let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "")
+ item.target = self
+ item.representedObject = mode
+ item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off
+ modeMenu.addItem(item)
+ }
+ let modeMenuItem = NSMenuItem(title: "⚙️ حالت کارکرد", action: nil, keyEquivalent: "")
+ modeMenuItem.submenu = modeMenu
+ menu.addItem(modeMenuItem)
+
+ // 3. Hotkey Submenu
+ let hotkeyMenu = NSMenu()
+ for preset in HotkeyPreset.allCases {
+ let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "")
+ item.target = self
+ item.representedObject = preset
+ item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off
+ hotkeyMenu.addItem(item)
+ }
+ let hotkeyMenuItem = NSMenuItem(title: "⌨️ کلید میانبر (Hotkey)", action: nil, keyEquivalent: "")
+ hotkeyMenuItem.submenu = hotkeyMenu
+ menu.addItem(hotkeyMenuItem)
+
+ menu.addItem(NSMenuItem.separator())
+
+ // 4. Sound Effects Toggle
+ let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds")
+ let soundItem = NSMenuItem(title: "🔊 پخش افکت صوتی", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "")
+ soundItem.target = self
+ soundItem.state = soundsEnabled ? .on : .off
+ menu.addItem(soundItem)
+
+ // 5. Accessibility Permission Check
+ let isAxTrusted = AXIsProcessTrusted()
+ let axTitle = isAxTrusted ? "✅ دسترسی Accessibility فعال است" : "🔑 اعطای دسترسی Accessibility..."
+ let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "")
+ axItem.target = self
+ menu.addItem(axItem)
+
+ // 6. Launch at Login
+ let launchLogin = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin")
+ let launchItem = NSMenuItem(title: "🚀 اجرا هنگام بالا آمدن سیستم", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "")
+ launchItem.target = self
+ launchItem.state = launchLogin ? .on : .off
+ menu.addItem(launchItem)
+
+ menu.addItem(NSMenuItem.separator())
+
+ // 7. About & Quit
+ let aboutItem = NSMenuItem(title: "ℹ️ درباره Soniox Voice", action: #selector(aboutAction), keyEquivalent: "")
+ aboutItem.target = self
+ menu.addItem(aboutItem)
+
+ let quitItem = NSMenuItem(title: "❌ خروج", action: #selector(quitAction), keyEquivalent: "q")
+ quitItem.target = self
+ menu.addItem(quitItem)
+
+ statusItem?.menu = menu
+ }
+
+ @objc private func toggleRecordAction() {
+ onToggleRecording?()
+ }
+
+ @objc private func selectModeAction(_ sender: NSMenuItem) {
+ if let mode = sender.representedObject as? DictationMode {
+ HotkeyManager.shared.currentMode = mode
+ buildMenu()
+ }
+ }
+
+ @objc private func selectHotkeyAction(_ sender: NSMenuItem) {
+ if let preset = sender.representedObject as? HotkeyPreset {
+ HotkeyManager.shared.currentPreset = preset
+ buildMenu()
+ }
+ }
+
+ @objc private func toggleSoundsAction(_ sender: NSMenuItem) {
+ let current = UserDefaults.standard.bool(forKey: "SonioxPlaySounds")
+ UserDefaults.standard.set(!current, forKey: "SonioxPlaySounds")
+ buildMenu()
+ }
+
+ @objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) {
+ let current = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin")
+ UserDefaults.standard.set(!current, forKey: "SonioxLaunchAtLogin")
+ buildMenu()
+ }
+
+ @objc private func openAccessibilitySettings() {
+ let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
+ NSWorkspace.shared.open(url)
+ }
+
+ @objc private func aboutAction() {
+ let alert = NSAlert()
+ alert.messageText = "Soniox Voice v1.0"
+ alert.informativeText = "تبدیل بلادرنگ گفتار به متن فارسی و انگلیسی با موتور ابری فوق سریع Soniox.\n\nتوسعه یافته برای macOS."
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: "باشه")
+ alert.runModal()
+ }
+
+ @objc private func quitAction() {
+ NSApplication.shared.terminate(nil)
+ }
+}
diff --git a/mac/src/main.swift b/mac/src/main.swift
new file mode 100644
index 0000000..467311d
--- /dev/null
+++ b/mac/src/main.swift
@@ -0,0 +1,7 @@
+import Cocoa
+
+let app = NSApplication.shared
+let delegate = AppDelegate()
+app.delegate = delegate
+app.setActivationPolicy(.accessory)
+_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
diff --git a/mac/test_apps_ax.swift b/mac/test_apps_ax.swift
new file mode 100644
index 0000000..b3698cc
--- /dev/null
+++ b/mac/test_apps_ax.swift
@@ -0,0 +1,42 @@
+import Cocoa
+import ApplicationServices
+
+func inspectApp(named targetName: String) {
+ guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.localizedName == targetName }) else {
+ print("App \(targetName) not running")
+ return
+ }
+
+ print("\n--- Inspecting \(targetName) (PID: \(app.processIdentifier)) ---")
+ let appElem = AXUIElementCreateApplication(app.processIdentifier)
+ AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ var focusedElemObj: CFTypeRef?
+ let err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj)
+ print("kAXFocusedUIElementAttribute error:", err.rawValue)
+
+ if err == .success, let elem = focusedElemObj {
+ let axElem = elem as! AXUIElement
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj)
+ print("Role:", roleObj ?? "none")
+
+ var valObj: CFTypeRef?
+ let valErr = AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj)
+ print("Value err:", valErr.rawValue, "Value:", valObj ?? "none")
+
+ var rangeObj: CFTypeRef?
+ let rangeErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeObj)
+ if rangeErr == .success, let axRange = rangeObj {
+ var range = CFRange()
+ if AXValueGetValue(axRange as! AXValue, .cfRange, &range) {
+ print("Cursor: loc=\(range.location), len=\(range.length)")
+ }
+ }
+ }
+}
+
+inspectApp(named: "Telegram")
+inspectApp(named: "Obsidian")
+inspectApp(named: "firefox")
diff --git a/mac/test_focus_detector.swift b/mac/test_focus_detector.swift
new file mode 100644
index 0000000..d6a7ffc
--- /dev/null
+++ b/mac/test_focus_detector.swift
@@ -0,0 +1,107 @@
+import Cocoa
+import ApplicationServices
+
+// Comprehensive recursive search for any element with AXFocused == true
+func findActiveFocusedElement(_ elem: AXUIElement) -> AXUIElement? {
+ var isFocusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &isFocusedObj) == .success,
+ let isFocused = isFocusedObj as? Bool, isFocused {
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+ if role != "AXWindow" && role != "AXApplication" && role != "AXGroup" && role != "AXSplitGroup" && role != "AXScrollArea" {
+ return elem
+ }
+ }
+
+ var childrenObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success,
+ let children = childrenObj as? [AXUIElement] {
+ for child in children {
+ if let found = findActiveFocusedElement(child) {
+ return found
+ }
+ }
+ }
+ return nil
+}
+
+func getFocusedTextInfo() -> (String, String, Int, Int)? {
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
+ let appName = frontApp.localizedName ?? "App"
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+
+ // 1. Try systemWide
+ let sysWide = AXUIElementCreateSystemWide()
+ var focusedElemObj: CFTypeRef?
+ var targetElem: AXUIElement?
+
+ if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) == .success,
+ let obj = focusedElemObj {
+ targetElem = (obj as! AXUIElement)
+ }
+
+ // 2. Try App focused element
+ if targetElem == nil {
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) == .success,
+ let obj = focusedElemObj {
+ targetElem = (obj as! AXUIElement)
+ }
+ }
+
+ // 3. Try Recursive search in app tree
+ if targetElem == nil {
+ targetElem = findActiveFocusedElement(appElem)
+ }
+
+ guard let elem = targetElem else { return nil }
+
+ // Extract text
+ var text = ""
+ var valObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success,
+ let val = valObj {
+ if let str = val as? String {
+ text = str
+ } else if let attrStr = val as? NSAttributedString {
+ text = attrStr.string
+ }
+ }
+
+ // Try parameterized string for range
+ if text.isEmpty {
+ var countObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countObj) == .success,
+ let count = countObj as? Int, count > 0 {
+ var range = CFRange(location: 0, length: count)
+ if let axRange = AXValueCreate(.cfRange, &range) {
+ var strObj: CFTypeRef?
+ if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &strObj) == .success,
+ let str = strObj as? String {
+ text = str
+ }
+ }
+ }
+ }
+
+ // Cursor & Selection
+ var cursor = text.count
+ var selLen = 0
+ var rangeObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) == .success,
+ let axRange = rangeObj {
+ var range = CFRange()
+ if AXValueGetValue(axRange as! AXValue, .cfRange, &range) {
+ cursor = range.location
+ selLen = range.length
+ }
+ }
+
+ return (appName, text, cursor, selLen)
+}
+
+if let (app, text, cursor, selLen) = getFocusedTextInfo() {
+ print("Found! App: \(app), Text: '\(text)', Cursor: \(cursor), SelLen: \(selLen)")
+} else {
+ print("No focused text element found")
+}
diff --git a/mac/test_full_ax.swift b/mac/test_full_ax.swift
new file mode 100644
index 0000000..6d5c906
--- /dev/null
+++ b/mac/test_full_ax.swift
@@ -0,0 +1,110 @@
+import Cocoa
+import ApplicationServices
+
+func extractTextAndCursor(from elem: AXUIElement) -> (String, Int, Int)? {
+ var roleRef: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
+ let role = (roleRef as? String) ?? ""
+
+ var currentText = ""
+ var valueRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valueRef) == .success,
+ let val = valueRef {
+ if let str = val as? String {
+ currentText = str
+ } else if let attrStr = val as? NSAttributedString {
+ currentText = attrStr.string
+ }
+ }
+
+ if currentText.isEmpty {
+ var countRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countRef) == .success,
+ let count = countRef as? Int, count > 0 {
+ var range = CFRange(location: 0, length: count)
+ if let axRange = AXValueCreate(.cfRange, &range) {
+ var stringRef: CFTypeRef?
+ if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &stringRef) == .success,
+ let str = stringRef as? String {
+ currentText = str
+ }
+ }
+ }
+ }
+
+ var cursor = currentText.count
+ var selLen = 0
+ var rangeRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success,
+ let val = rangeRef {
+ var cfRange = CFRange()
+ if AXValueGetValue(val as! AXValue, .cfRange, &cfRange) {
+ cursor = cfRange.location
+ selLen = cfRange.length
+ }
+ }
+
+ if !currentText.isEmpty || role == "AXTextField" || role == "AXTextArea" || role == "AXSearchField" || rangeRef != nil {
+ return (currentText, cursor, selLen)
+ }
+
+ // Check focused child
+ var focusedChildRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXFocusedUIElementAttribute as CFString, &focusedChildRef) == .success,
+ let child = focusedChildRef {
+ if let res = extractTextAndCursor(from: child as! AXUIElement) {
+ return res
+ }
+ }
+
+ // Check children
+ var childrenRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenRef) == .success,
+ let children = childrenRef as? [AXUIElement] {
+ for child in children {
+ var isFocusedRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(child, kAXFocusedAttribute as CFString, &isFocusedRef) == .success,
+ let isFoc = isFocusedRef as? Bool, isFoc {
+ if let res = extractTextAndCursor(from: child) {
+ return res
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func testFullAX() {
+ let sysWide = AXUIElementCreateSystemWide()
+ AXUIElementSetAttributeValue(sysWide, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(sysWide, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else { return }
+ print("Front App:", frontApp.localizedName ?? "")
+
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+ AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ var focusedElem: CFTypeRef?
+ if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedElem) == .success,
+ let elem = focusedElem {
+ if let (text, cursor, sel) = extractTextAndCursor(from: elem as! AXUIElement) {
+ print("Extracted from sysWide -> Text: '\(text)', Cursor: \(cursor), Sel: \(sel)")
+ return
+ }
+ }
+
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElem) == .success,
+ let elem = focusedElem {
+ if let (text, cursor, sel) = extractTextAndCursor(from: elem as! AXUIElement) {
+ print("Extracted from appElem -> Text: '\(text)', Cursor: \(cursor), Sel: \(sel)")
+ return
+ }
+ }
+
+ print("Could not extract text")
+}
+
+testFullAX()
diff --git a/mac/test_inspect.swift b/mac/test_inspect.swift
new file mode 100644
index 0000000..f2cbadf
--- /dev/null
+++ b/mac/test_inspect.swift
@@ -0,0 +1,45 @@
+import Cocoa
+import ApplicationServices
+
+func inspect() {
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else {
+ print("No front app")
+ return
+ }
+ print("Front App:", frontApp.localizedName ?? "", "PID:", frontApp.processIdentifier)
+
+ let trusted = AXIsProcessTrusted()
+ print("AXIsProcessTrusted:", trusted)
+
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+ var focusedElemObj: CFTypeRef?
+ let err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj)
+ print("kAXFocusedUIElement error:", err.rawValue)
+
+ if err == .success, let elem = focusedElemObj {
+ let axElem = elem as! AXUIElement
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj)
+ print("Role:", roleObj ?? "none")
+
+ var valObj: CFTypeRef?
+ let valErr = AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj)
+ print("Value err:", valErr.rawValue, "Value:", valObj ?? "nil")
+
+ var selectedTextObj: CFTypeRef?
+ let selTxtErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextAttribute as CFString, &selectedTextObj)
+ print("SelectedText err:", selTxtErr.rawValue, "SelectedText:", selectedTextObj ?? "nil")
+
+ var rangeObj: CFTypeRef?
+ let rangeErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeObj)
+ print("Range err:", rangeErr.rawValue)
+ if rangeErr == .success, let axRange = rangeObj {
+ var range = CFRange()
+ if AXValueGetValue(axRange as! AXValue, .cfRange, &range) {
+ print("CFRange: loc=\(range.location), len=\(range.length)")
+ }
+ }
+ }
+}
+
+inspect()
diff --git a/mac/test_inspect2.swift b/mac/test_inspect2.swift
new file mode 100644
index 0000000..10143a3
--- /dev/null
+++ b/mac/test_inspect2.swift
@@ -0,0 +1,45 @@
+import Cocoa
+import ApplicationServices
+
+func getFocusedElement() -> AXUIElement? {
+ // 1. System wide
+ let sysWide = AXUIElementCreateSystemWide()
+ var sysFocusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &sysFocusedObj) == .success,
+ let obj = sysFocusedObj {
+ return (obj as! AXUIElement)
+ }
+
+ // 2. Frontmost App
+ if let frontApp = NSWorkspace.shared.frontmostApplication {
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+ var appFocusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &appFocusedObj) == .success,
+ let obj = appFocusedObj {
+ return (obj as! AXUIElement)
+ }
+ }
+ return nil
+}
+
+func inspectElement(_ elem: AXUIElement) {
+ var attrNamesObj: CFArray?
+ AXUIElementCopyAttributeNames(elem, &attrNamesObj)
+ if let names = attrNamesObj as? [String] {
+ print("Attribute Names:", names)
+ for name in names {
+ var val: CFTypeRef?
+ let err = AXUIElementCopyAttributeValue(elem, name as CFString, &val)
+ if err == .success, let val = val {
+ print(" \(name): \(val)")
+ }
+ }
+ }
+}
+
+if let focused = getFocusedElement() {
+ print("Found Focused Element:")
+ inspectElement(focused)
+} else {
+ print("No focused element found")
+}
diff --git a/mac/test_inspect3.swift b/mac/test_inspect3.swift
new file mode 100644
index 0000000..87217b8
--- /dev/null
+++ b/mac/test_inspect3.swift
@@ -0,0 +1,76 @@
+import Cocoa
+import ApplicationServices
+
+func findFocusedDescendant(_ elem: AXUIElement) -> AXUIElement? {
+ // Check if this element is a text field/text area or has value
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+
+ if role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXSearchField" {
+ return elem
+ }
+
+ var focusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &focusedObj) == .success,
+ let isFocused = focusedObj as? Bool, isFocused {
+ // If it has value attribute, return it
+ var valObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success {
+ return elem
+ }
+ }
+
+ // Check children
+ var childrenObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success,
+ let children = childrenObj as? [AXUIElement] {
+ for child in children {
+ if let found = findFocusedDescendant(child) {
+ return found
+ }
+ }
+ }
+ return nil
+}
+
+func getDeepFocusedElement() -> (AXUIElement, String, String)? {
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+
+ // First try standard focused element
+ var focusedObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
+ let elem = focusedObj as! AXUIElement? {
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? ""
+
+ var valObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj)
+ let val = (valObj as? String) ?? ""
+
+ if !val.isEmpty || role == "AXTextField" || role == "AXTextArea" {
+ return (elem, role, val)
+ }
+
+ // If it's a window or web area, search descendants
+ if let deep = findFocusedDescendant(elem) {
+ var deepRoleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(deep, kAXRoleAttribute as CFString, &deepRoleObj)
+ let deepRole = (deepRoleObj as? String) ?? ""
+
+ var deepValObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(deep, kAXValueAttribute as CFString, &deepValObj)
+ let deepVal = (deepValObj as? String) ?? ""
+ return (deep, deepRole, deepVal)
+ }
+ }
+ return nil
+}
+
+if let (elem, role, val) = getDeepFocusedElement() {
+ print("Found deep focused element! Role: \(role), Value: '\(val)'")
+} else {
+ print("Deep focused element not found")
+}
diff --git a/mac/test_live_ax.swift b/mac/test_live_ax.swift
new file mode 100644
index 0000000..32f9efc
--- /dev/null
+++ b/mac/test_live_ax.swift
@@ -0,0 +1,81 @@
+import Cocoa
+import ApplicationServices
+
+func testLiveAX() {
+ let sysWide = AXUIElementCreateSystemWide()
+
+ // Enable Chromium/Electron accessibility
+ AXUIElementSetAttributeValue(sysWide, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(sysWide, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else {
+ print("No front app")
+ return
+ }
+
+ print("Front App:", frontApp.localizedName ?? "", "PID:", frontApp.processIdentifier)
+
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+ AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
+ AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue)
+
+ // Try system wide focused element
+ var focusedUIElement: CFTypeRef?
+ var err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedUIElement)
+ if err != .success || focusedUIElement == nil {
+ err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedUIElement)
+ }
+
+ guard err == .success, let elem = focusedUIElement else {
+ print("No focused element. Error:", err.rawValue)
+ return
+ }
+
+ let axElem = elem as! AXUIElement
+
+ var roleRef: CFTypeRef?
+ AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleRef)
+ let role = (roleRef as? String) ?? "AXUnknown"
+ print("Role:", role)
+
+ // Extract text
+ var currentText = ""
+ var valueRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valueRef) == .success,
+ let val = valueRef {
+ if let str = val as? String {
+ currentText = str
+ } else if let attrStr = val as? NSAttributedString {
+ currentText = attrStr.string
+ }
+ }
+
+ if currentText.isEmpty {
+ var countRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(axElem, kAXNumberOfCharactersAttribute as CFString, &countRef) == .success,
+ let count = countRef as? Int {
+ var range = CFRange(location: 0, length: count)
+ if let axRange = AXValueCreate(.cfRange, &range) {
+ var stringRef: CFTypeRef?
+ if AXUIElementCopyParameterizedAttributeValue(axElem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &stringRef) == .success,
+ let str = stringRef as? String {
+ currentText = str
+ }
+ }
+ }
+ }
+
+ print("Extracted Text: '\(currentText)'")
+
+ // Selection / Cursor
+ var rangeRef: CFTypeRef?
+ if AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success,
+ let val = rangeRef {
+ var cfRange = CFRange()
+ if AXValueGetValue(val as! AXValue, .cfRange, &cfRange) {
+ print("Cursor Location: \(cfRange.location), Selection Length: \(cfRange.length)")
+ }
+ }
+}
+
+testLiveAX()
diff --git a/mac/test_timer.swift b/mac/test_timer.swift
new file mode 100644
index 0000000..6d83d84
--- /dev/null
+++ b/mac/test_timer.swift
@@ -0,0 +1,21 @@
+import Foundation
+import Cocoa
+
+class TestTimer {
+ private var timerSource: DispatchSourceTimer?
+
+ func start() {
+ let queue = DispatchQueue(label: "com.test.timer", qos: .userInteractive)
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now(), repeating: .milliseconds(50))
+ timer.setEventHandler {
+ if let (elem, app) = FocusedInputSync.shared.getFocusedElement() {
+ if let state = FocusedInputSync.shared.inspectCurrentState() {
+ print("Live State detected: '\(state.text)' in \(state.app)")
+ }
+ }
+ }
+ timer.resume()
+ self.timerSource = timer
+ }
+}
diff --git a/mac/test_tree.swift b/mac/test_tree.swift
new file mode 100644
index 0000000..d8094c0
--- /dev/null
+++ b/mac/test_tree.swift
@@ -0,0 +1,39 @@
+import Cocoa
+import ApplicationServices
+
+func printAXTree(_ elem: AXUIElement, depth: Int = 0) {
+ if depth > 7 { return }
+ let indent = String(repeating: " ", count: depth)
+
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
+ let role = (roleObj as? String) ?? "unknown"
+
+ var valObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj)
+ let val = (valObj as? String) ?? ""
+
+ var titleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXTitleAttribute as CFString, &titleObj)
+ let title = (titleObj as? String) ?? ""
+
+ var focusedObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &focusedObj)
+ let isFocused = (focusedObj as? Bool) ?? false
+
+ print("\(indent)[\(role)] title='\(title)' val='\(val)' focused=\(isFocused)")
+
+ var childrenObj: CFTypeRef?
+ if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success,
+ let children = childrenObj as? [AXUIElement] {
+ for child in children {
+ printAXTree(child, depth: depth + 1)
+ }
+ }
+}
+
+if let frontApp = NSWorkspace.shared.frontmostApplication {
+ print("Front App:", frontApp.localizedName ?? "")
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+ printAXTree(appElem)
+}
diff --git a/mac/test_win_ax.swift b/mac/test_win_ax.swift
new file mode 100644
index 0000000..7fb1f05
--- /dev/null
+++ b/mac/test_win_ax.swift
@@ -0,0 +1,33 @@
+import Cocoa
+import ApplicationServices
+
+func inspectFrontApp() {
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else { return }
+ print("Frontmost App:", frontApp.localizedName ?? "")
+
+ let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
+
+ // 1. Try focused window
+ var windowObj: CFTypeRef?
+ var err = AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj)
+ print("kAXFocusedWindowAttribute err:", err.rawValue)
+
+ if err == .success, let win = windowObj {
+ let winElem = win as! AXUIElement
+ var focusedObj: CFTypeRef?
+ let winErr = AXUIElementCopyAttributeValue(winElem, kAXFocusedUIElementAttribute as CFString, &focusedObj)
+ print("Window focused element err:", winErr.rawValue)
+ if winErr == .success, let elem = focusedObj {
+ let axElem = elem as! AXUIElement
+ var roleObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj)
+ print("Role from window:", roleObj ?? "none")
+
+ var valObj: CFTypeRef?
+ AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj)
+ print("Value from window:", valObj ?? "none")
+ }
+ }
+}
+
+inspectFrontApp()