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