commit 336b01b612091eee19bb68ef24117a2cfec60bfb Author: Ali Alavi Date: Sat Sep 5 09:30:28 2026 +0000 revert: restore clean standalone macOS speech-to-text app (Aug 22 state) diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..496a3f9 --- /dev/null +++ b/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/make_icon.py b/make_icon.py new file mode 100644 index 0000000..7b012d7 --- /dev/null +++ b/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/package.sh b/package.sh new file mode 100644 index 0000000..88c409c --- /dev/null +++ b/package.sh @@ -0,0 +1,55 @@ +#!/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. Ad-hoc Codesign +codesign --force --deep --sign - "$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!" +ls -lh "$BUILD_DIR" diff --git a/resources/AppIcon.iconset/icon_128x128.png b/resources/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..27dbadb Binary files /dev/null and b/resources/AppIcon.iconset/icon_128x128.png differ diff --git a/resources/AppIcon.iconset/icon_128x128@2x.png b/resources/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..6d1fa4d Binary files /dev/null and b/resources/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/resources/AppIcon.iconset/icon_16x16.png b/resources/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..aca1b51 Binary files /dev/null and b/resources/AppIcon.iconset/icon_16x16.png differ diff --git a/resources/AppIcon.iconset/icon_16x16@2x.png b/resources/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..a542351 Binary files /dev/null and b/resources/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/resources/AppIcon.iconset/icon_256x256.png b/resources/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..6d1fa4d Binary files /dev/null and b/resources/AppIcon.iconset/icon_256x256.png differ diff --git a/resources/AppIcon.iconset/icon_256x256@2x.png b/resources/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..2fa55f0 Binary files /dev/null and b/resources/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/resources/AppIcon.iconset/icon_32x32.png b/resources/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..a542351 Binary files /dev/null and b/resources/AppIcon.iconset/icon_32x32.png differ diff --git a/resources/AppIcon.iconset/icon_32x32@2x.png b/resources/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..bee1a97 Binary files /dev/null and b/resources/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/resources/AppIcon.iconset/icon_512x512.png b/resources/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..2fa55f0 Binary files /dev/null and b/resources/AppIcon.iconset/icon_512x512.png differ diff --git a/resources/AppIcon.iconset/icon_512x512@2x.png b/resources/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..7c5030a Binary files /dev/null and b/resources/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/resources/AppIcon.png b/resources/AppIcon.png new file mode 100644 index 0000000..7c5030a Binary files /dev/null and b/resources/AppIcon.png differ diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift new file mode 100644 index 0000000..b67bd19 --- /dev/null +++ b/src/AppDelegate.swift @@ -0,0 +1,219 @@ +import Cocoa +import AVFoundation +import ApplicationServices + +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 + + public func applicationDidFinishLaunching(_ notification: Notification) { + if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { + UserDefaults.standard.set(true, forKey: "SonioxPlaySounds") + } + + statusBarController = StatusBarController() + statusBarController.onToggleRecording = { [weak self] in + self?.toggleRecording() + } + + // Setup Hotkey manager callbacks (Push-To-Talk & Toggle) + 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.stopRecording() + } + } + } + + HotkeyManager.shared.registerHotkeys() + + // Listen to live audio levels & update HUD + 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)) + } + } + + // Stream audio chunk live directly to active Soniox session in real-time + audioRecorder.onAudioChunkAvailable = { [weak self] chunk in + self?.activeSession?.sendAudioChunk(chunk) + } + + // Pre-warm the background WebSocket session for 0ms startup delay + SonioxSessionPool.shared.prewarmNextSession() + + // Check permissions on start + checkInitialPermissions() + } + + private func checkInitialPermissions() { + // 1. Microphone + audioRecorder.requestMicrophonePermission { granted in + if !granted { + print("Microphone permission not granted yet.") + } + } + + // 2. Accessibility + if !AXIsProcessTrusted() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + AXIsProcessTrustedWithOptions(options) + } + } + + private func playSystemSound(name: String) { + let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") + if soundsEnabled { + NSSound(named: NSSound.Name(name))?.play() + } + } + + public func toggleRecording() { + if audioRecorder.isRecording { + stopRecording() + } else { + startRecording() + } + } + + public func startRecording() { + if audioRecorder.isRecording || isBusyFinalizing { return } + + latestPartialText = nil + + // Acquire pre-warmed / fast-connected WebSocket session + let session = SonioxSessionPool.shared.acquireSession() + self.activeSession = session + + session.onPartialText = { [weak self] partial in + guard let self = self else { return } + self.latestPartialText = partial + if self.audioRecorder.isRecording { + HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: partial)) + } + } + + session.onFinalResult = { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isBusyFinalizing = false + self.activeSession = nil + self.statusBarController.updateIcon(state: .idle) + + switch result { + case .success(let text): + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + HUDOverlayController.shared.show(state: .success(text: trimmed)) + self.pasteTextToFrontmostApp(text: trimmed) + } else { + HUDOverlayController.shared.show(state: .error(message: "متنی تشخیص داده نشد")) + } + case .failure(let error): + print("Soniox finalize error:", error) + HUDOverlayController.shared.show(state: .error(message: "خطا در اتصال به سرور Soniox")) + } + } + } + + do { + try audioRecorder.startRecording() + playSystemSound(name: "Tink") + statusBarController.updateIcon(state: .recording) + statusBarController.buildMenu(isRecording: true) + HUDOverlayController.shared.show(state: .recording(level: 0.1, liveText: nil)) + } catch { + print("Failed to start audio recording:", error) + activeSession?.cancel() + activeSession = nil + HUDOverlayController.shared.show(state: .error(message: "عدم دسترسی به میکروفون")) + } + } + + public func stopRecording() { + guard audioRecorder.isRecording else { return } + + _ = audioRecorder.stopRecording() + playSystemSound(name: "Pop") + statusBarController.updateIcon(state: .transcribing) + statusBarController.buildMenu(isRecording: false) + HUDOverlayController.shared.show(state: .transcribing) + + isBusyFinalizing = true + + // Safety timeout: If internet drops or server halts during finalization, guarantee completion within 1.2s + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in + guard let self = self, self.isBusyFinalizing else { return } + print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") + self.activeSession?.completeWithCurrentText() + } + + activeSession?.finalizeStream() + } + + private func pasteTextToFrontmostApp(text: String) { + guard !text.isEmpty else { return } + + // 1. Put into system pasteboard with a trailing space + let textToPaste = text + " " + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(textToPaste, forType: .string) + + // 2. Check Accessibility permission + if !AXIsProcessTrusted() { + print("Accessibility permission NOT granted for auto-paste!") + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + AXIsProcessTrustedWithOptions(options) + HUDOverlayController.shared.show(state: .error(message: "نیاز به تیک Accessibility برای تایپ خودکار")) + return + } + + // 3. Instant paste via CGEvent with minimal delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let source = CGEventSource(stateID: .combinedSessionState) + let vKeyCode: CGKeyCode = 0x09 // 'v' key + + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true) + keyDown?.flags = .maskCommand + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) + keyUp?.flags = .maskCommand + + keyDown?.post(tap: .cgAnnotatedSessionEventTap) + keyUp?.post(tap: .cgAnnotatedSessionEventTap) + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) + + // Backup via AppleScript + let script = NSAppleScript(source: """ + tell application "System Events" + keystroke "v" using command down + end tell + """) + var errDict: NSDictionary? + script?.executeAndReturnError(&errDict) + } + } + + public func applicationWillTerminate(_ notification: Notification) { + HotkeyManager.shared.unregisterHotkeys() + } +} diff --git a/src/AudioRecorder.swift b/src/AudioRecorder.swift new file mode 100644 index 0000000..d712c65 --- /dev/null +++ b/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/src/HUDOverlay.swift b/src/HUDOverlay.swift new file mode 100644 index 0000000..24f7ca6 --- /dev/null +++ b/src/HUDOverlay.swift @@ -0,0 +1,185 @@ +import Cocoa + +public enum HUDState { + case hidden + case recording(level: Float, liveText: String? = nil) + case transcribing + case success(text: String) + case error(message: String) +} + +public final class HUDOverlayController { + public static let shared = HUDOverlayController() + + private var window: NSPanel? + private var visualEffectView: NSVisualEffectView? + private var iconImageView: NSImageView? + private var titleLabel: NSTextField? + private var subtitleLabel: NSTextField? + + private var hideTimer: Timer? + + private init() { + setupWindow() + } + + private func setupWindow() { + let width: CGFloat = 460 + let height: CGFloat = 80 + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: width, height: height)) + visualEffect.material = .hudWindow + visualEffect.blendingMode = .behindWindow + visualEffect.state = .active + visualEffect.wantsLayer = true + visualEffect.layer?.cornerRadius = 24 + visualEffect.layer?.masksToBounds = true + visualEffect.layer?.borderWidth = 1.2 + visualEffect.layer?.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor + + // Icon Image View + let iconView = NSImageView(frame: NSRect(x: 18, y: (height - 42) / 2, width: 42, height: 42)) + iconView.imageScaling = .scaleProportionallyUpOrDown + + // Title Label + let tLabel = NSTextField(frame: NSRect(x: 72, y: 40, width: width - 90, height: 24)) + tLabel.isBezeled = false + tLabel.drawsBackground = false + tLabel.isEditable = false + tLabel.isSelectable = false + tLabel.font = NSFont.systemFont(ofSize: 14, weight: .bold) + tLabel.textColor = .white + tLabel.alignment = .left + + // Subtitle / Preview Label + let sLabel = NSTextField(frame: NSRect(x: 72, y: 14, width: width - 90, height: 22)) + sLabel.isBezeled = false + sLabel.drawsBackground = false + sLabel.isEditable = false + sLabel.isSelectable = false + sLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + sLabel.textColor = NSColor.white.withAlphaComponent(0.9) + sLabel.alignment = .left + + visualEffect.addSubview(iconView) + visualEffect.addSubview(tLabel) + visualEffect.addSubview(sLabel) + + panel.contentView = visualEffect + + self.window = panel + self.visualEffectView = visualEffect + self.iconImageView = iconView + self.titleLabel = tLabel + self.subtitleLabel = sLabel + } + + public func show(state: HUDState) { + hideTimer?.invalidate() + hideTimer = nil + + guard let panel = self.window else { return } + + // Position at bottom center of current active screen + if let screen = NSScreen.main { + let screenRect = screen.visibleFrame + let x = screenRect.origin.x + (screenRect.width - panel.frame.width) / 2 + let y = screenRect.origin.y + 60 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } + + switch state { + case .hidden: + hide(animated: true) + return + + case .recording(let level, let liveText): + let micImage = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") + let scale: CGFloat = 22.0 + CGFloat(level) * 6.0 + iconImageView?.image = micImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: scale, weight: .bold)) + iconImageView?.contentTintColor = NSColor.systemRed + titleLabel?.stringValue = "🎙️ در حال تبدیل زنده صدا..." + + if let live = liveText, !live.isEmpty { + let preview = live.count > 46 ? "..." + String(live.suffix(46)) : live + subtitleLabel?.stringValue = preview + subtitleLabel?.textColor = NSColor.systemGreen.withAlphaComponent(0.95) + } else { + let mode = HotkeyManager.shared.currentMode == .toggle ? "پایان: کلیک مجدد" : "رها کردن کلید ⌥ جهت درج متن" + subtitleLabel?.stringValue = mode + subtitleLabel?.textColor = NSColor.systemRed.withAlphaComponent(0.9) + } + + case .transcribing: + let waveImage = NSImage(systemSymbolName: "waveform.badge.magnifyingglass", accessibilityDescription: "Transcribing") + iconImageView?.image = waveImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) + iconImageView?.contentTintColor = NSColor.systemOrange + titleLabel?.stringValue = "⚡ درج آنی متن..." + subtitleLabel?.stringValue = "در حال نهایی‌سازی و درج متن..." + subtitleLabel?.textColor = NSColor.systemOrange.withAlphaComponent(0.9) + + // Auto-dismiss watchdog: .transcribing state must NEVER hang on screen indefinitely + hideTimer = Timer.scheduledTimer(withTimeInterval: 1.8, repeats: false) { [weak self] _ in + self?.hide(animated: true) + } + + case .success(let text): + let checkImage = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: "Done") + iconImageView?.image = checkImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) + iconImageView?.contentTintColor = NSColor.systemGreen + titleLabel?.stringValue = "✨ متن درج شد" + let preview = text.count > 46 ? String(text.prefix(46)) + "..." : text + subtitleLabel?.stringValue = preview.isEmpty ? "کلیپ‌بورد به‌روز شد" : preview + subtitleLabel?.textColor = NSColor.white.withAlphaComponent(0.95) + + hideTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { [weak self] _ in + self?.hide(animated: true) + } + + case .error(let msg): + let errImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Error") + iconImageView?.image = errImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold)) + iconImageView?.contentTintColor = NSColor.systemYellow + titleLabel?.stringValue = "⚠️ خطا در تبدیل صوت" + subtitleLabel?.stringValue = msg + subtitleLabel?.textColor = NSColor.systemYellow.withAlphaComponent(0.9) + + hideTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + self?.hide(animated: true) + } + } + + panel.alphaValue = 1.0 + panel.orderFrontRegardless() + } + + public func hide(animated: Bool) { + guard let panel = self.window, panel.isVisible else { return } + + if animated { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.15 + panel.animator().alphaValue = 0.0 + }, completionHandler: { + panel.orderOut(nil) + }) + } else { + panel.alphaValue = 0.0 + panel.orderOut(nil) + } + } +} diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift new file mode 100644 index 0000000..b5f68a7 --- /dev/null +++ b/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/src/SonioxClient.swift b/src/SonioxClient.swift new file mode 100644 index 0000000..f0451bf --- /dev/null +++ b/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/src/StatusBarController.swift b/src/StatusBarController.swift new file mode 100644 index 0000000..29457fc --- /dev/null +++ b/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/src/main.swift b/src/main.swift new file mode 100644 index 0000000..467311d --- /dev/null +++ b/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/test_apps_ax.swift b/test_apps_ax.swift new file mode 100644 index 0000000..b3698cc --- /dev/null +++ b/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/test_focus_detector.swift b/test_focus_detector.swift new file mode 100644 index 0000000..d6a7ffc --- /dev/null +++ b/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/test_full_ax.swift b/test_full_ax.swift new file mode 100644 index 0000000..6d5c906 --- /dev/null +++ b/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/test_inspect.swift b/test_inspect.swift new file mode 100644 index 0000000..f2cbadf --- /dev/null +++ b/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/test_inspect2.swift b/test_inspect2.swift new file mode 100644 index 0000000..10143a3 --- /dev/null +++ b/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/test_inspect3.swift b/test_inspect3.swift new file mode 100644 index 0000000..87217b8 --- /dev/null +++ b/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/test_live_ax.swift b/test_live_ax.swift new file mode 100644 index 0000000..32f9efc --- /dev/null +++ b/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/test_timer.swift b/test_timer.swift new file mode 100644 index 0000000..6d83d84 --- /dev/null +++ b/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/test_tree.swift b/test_tree.swift new file mode 100644 index 0000000..d8094c0 --- /dev/null +++ b/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/test_win_ax.swift b/test_win_ax.swift new file mode 100644 index 0000000..7fb1f05 --- /dev/null +++ b/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()