Browse Source

release(v1.001): full monorepo sync (Android App v1.001, Mac Client silent HUD, Linux Relay Server)

main
Ali Alavi 4 hours ago
parent
commit
2ea5a1c841
  1. 41
      mac/Info.plist
  2. 121
      mac/make_icon.py
  3. 54
      mac/package.sh
  4. BIN
      mac/resources/AppIcon.icns
  5. BIN
      mac/resources/AppIcon.iconset/icon_128x128.png
  6. BIN
      mac/resources/AppIcon.iconset/[email protected]
  7. BIN
      mac/resources/AppIcon.iconset/icon_16x16.png
  8. BIN
      mac/resources/AppIcon.iconset/[email protected]
  9. BIN
      mac/resources/AppIcon.iconset/icon_256x256.png
  10. BIN
      mac/resources/AppIcon.iconset/[email protected]
  11. BIN
      mac/resources/AppIcon.iconset/icon_32x32.png
  12. BIN
      mac/resources/AppIcon.iconset/[email protected]
  13. BIN
      mac/resources/AppIcon.iconset/icon_512x512.png
  14. BIN
      mac/resources/AppIcon.iconset/[email protected]
  15. BIN
      mac/resources/AppIcon.png
  16. 242
      mac/src/AppDelegate.swift
  17. 165
      mac/src/AudioRecorder.swift
  18. 380
      mac/src/FocusedInputSync.swift
  19. 24
      mac/src/HUDOverlay.swift
  20. 261
      mac/src/HotkeyManager.swift
  21. 205
      mac/src/RelayClient.swift
  22. 321
      mac/src/SonioxClient.swift
  23. 172
      mac/src/StatusBarController.swift
  24. 7
      mac/src/main.swift
  25. 42
      mac/test_apps_ax.swift
  26. 107
      mac/test_focus_detector.swift
  27. 110
      mac/test_full_ax.swift
  28. 45
      mac/test_inspect.swift
  29. 45
      mac/test_inspect2.swift
  30. 76
      mac/test_inspect3.swift
  31. 81
      mac/test_live_ax.swift
  32. 21
      mac/test_timer.swift
  33. 39
      mac/test_tree.swift
  34. 33
      mac/test_win_ax.swift

41
mac/Info.plist

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Soniox Voice</string>
<key>CFBundleExecutable</key>
<string>SonioxVoice</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.soniox.voice</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>SonioxVoice</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Soniox Voice به دسترسی میکروفون جهت ضبط صدا و تبدیل آن به متن نیاز دارد.</string>
<key>NSAccessibilityUsageDescription</key>
<string>Soniox Voice به دسترسی Accessibility جهت درج خودکار متن در برنامه فعال نیاز دارد.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

121
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, "[email protected]"),
(32, "icon_32x32.png"),
(64, "[email protected]"),
(128, "icon_128x128.png"),
(256, "[email protected]"),
(256, "icon_256x256.png"),
(512, "[email protected]"),
(512, "icon_512x512.png"),
(1024, "[email protected]"),
]
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")

54
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!"

BIN
mac/resources/AppIcon.icns

BIN
mac/resources/AppIcon.iconset/icon_128x128.png

After

Width: 128  |  Height: 128  |  Size: 10 KiB

BIN
mac/resources/AppIcon.iconset/[email protected]

After

Width: 256  |  Height: 256  |  Size: 23 KiB

BIN
mac/resources/AppIcon.iconset/icon_16x16.png

After

Width: 16  |  Height: 16  |  Size: 686 B

BIN
mac/resources/AppIcon.iconset/[email protected]

After

Width: 32  |  Height: 32  |  Size: 1.9 KiB

BIN
mac/resources/AppIcon.iconset/icon_256x256.png

After

Width: 256  |  Height: 256  |  Size: 23 KiB

BIN
mac/resources/AppIcon.iconset/[email protected]

After

Width: 512  |  Height: 512  |  Size: 47 KiB

BIN
mac/resources/AppIcon.iconset/icon_32x32.png

After

Width: 32  |  Height: 32  |  Size: 1.9 KiB

BIN
mac/resources/AppIcon.iconset/[email protected]

After

Width: 64  |  Height: 64  |  Size: 4.5 KiB

BIN
mac/resources/AppIcon.iconset/icon_512x512.png

After

Width: 512  |  Height: 512  |  Size: 47 KiB

BIN
mac/resources/AppIcon.iconset/[email protected]

After

Width: 1024  |  Height: 1024  |  Size: 31 KiB

BIN
mac/resources/AppIcon.png

After

Width: 1024  |  Height: 1024  |  Size: 31 KiB

242
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()
}
}

165
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<Int8>?
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<Int16>.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..<frameLen {
let sample = Float(int16Ptr[i]) / 32768.0
sumSquare += sample * sample
}
let rms = sqrt(sumSquare / Float(frameLen))
let normalizedLevel = min(max(rms * 4.5, 0.0), 1.0)
DispatchQueue.main.async { [weak self] in
self?.onAudioLevelUpdate?(normalizedLevel)
}
}
}
}
public func stopRecording() -> 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
}
}

380
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
}
}

24
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
}
}

261
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<HotkeyManager>.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<CGEvent>? in
guard let refcon = refcon else { return Unmanaged.passRetained(event) }
let manager = Unmanaged<HotkeyManager>.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
}
}
}

205
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()
}
}

321
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<String, Error>) -> 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("<fin>") {
gotFin = true
let clean = pText.replacingOccurrences(of: "<fin>", 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<String> = ["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()
}
}

172
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)
}
}

7
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)

42
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")

107
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")
}

110
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()

45
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()

45
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")
}

76
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")
}

81
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()

21
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
}
}

39
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)
}

33
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()
Loading…
Cancel
Save