release(v1.001): full monorepo sync (Android App v1.001, Mac Client silent HUD, Linux Relay Server)
main
-
41mac/Info.plist
-
121mac/make_icon.py
-
54mac/package.sh
-
BINmac/resources/AppIcon.icns
-
BINmac/resources/AppIcon.iconset/icon_128x128.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_16x16.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_256x256.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_32x32.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.iconset/icon_512x512.png
-
BINmac/resources/AppIcon.iconset/[email protected]
-
BINmac/resources/AppIcon.png
-
242mac/src/AppDelegate.swift
-
165mac/src/AudioRecorder.swift
-
380mac/src/FocusedInputSync.swift
-
24mac/src/HUDOverlay.swift
-
261mac/src/HotkeyManager.swift
-
205mac/src/RelayClient.swift
-
321mac/src/SonioxClient.swift
-
172mac/src/StatusBarController.swift
-
7mac/src/main.swift
-
42mac/test_apps_ax.swift
-
107mac/test_focus_detector.swift
-
110mac/test_full_ax.swift
-
45mac/test_inspect.swift
-
45mac/test_inspect2.swift
-
76mac/test_inspect3.swift
-
81mac/test_live_ax.swift
-
21mac/test_timer.swift
-
39mac/test_tree.swift
-
33mac/test_win_ax.swift
@ -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> |
|||
@ -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") |
|||
@ -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!" |
|||
|
After Width: 128 | Height: 128 | Size: 10 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 16 | Height: 16 | Size: 686 B |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 64 | Height: 64 | Size: 4.5 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
@ -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() |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
import Cocoa |
|||
|
|||
let app = NSApplication.shared |
|||
let delegate = AppDelegate() |
|||
app.delegate = delegate |
|||
app.setActivationPolicy(.accessory) |
|||
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) |
|||
@ -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") |
|||
@ -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") |
|||
} |
|||
@ -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() |
|||
@ -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() |
|||
@ -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") |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -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() |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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() |
|||