-
41Info.plist
-
121make_icon.py
-
55package.sh
-
BINresources/AppIcon.iconset/icon_128x128.png
-
BINresources/AppIcon.iconset/icon_128x128@2x.png
-
BINresources/AppIcon.iconset/icon_16x16.png
-
BINresources/AppIcon.iconset/icon_16x16@2x.png
-
BINresources/AppIcon.iconset/icon_256x256.png
-
BINresources/AppIcon.iconset/icon_256x256@2x.png
-
BINresources/AppIcon.iconset/icon_32x32.png
-
BINresources/AppIcon.iconset/icon_32x32@2x.png
-
BINresources/AppIcon.iconset/icon_512x512.png
-
BINresources/AppIcon.iconset/icon_512x512@2x.png
-
BINresources/AppIcon.png
-
219src/AppDelegate.swift
-
165src/AudioRecorder.swift
-
185src/HUDOverlay.swift
-
261src/HotkeyManager.swift
-
321src/SonioxClient.swift
-
172src/StatusBarController.swift
-
7src/main.swift
-
42test_apps_ax.swift
-
107test_focus_detector.swift
-
110test_full_ax.swift
-
45test_inspect.swift
-
45test_inspect2.swift
-
76test_inspect3.swift
-
81test_live_ax.swift
-
21test_timer.swift
-
39test_tree.swift
-
33test_win_ax.swift
@ -0,0 +1,41 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>CFBundleDevelopmentRegion</key> |
||||
|
<string>en</string> |
||||
|
<key>CFBundleDisplayName</key> |
||||
|
<string>Soniox Voice</string> |
||||
|
<key>CFBundleExecutable</key> |
||||
|
<string>SonioxVoice</string> |
||||
|
<key>CFBundleIconFile</key> |
||||
|
<string>AppIcon</string> |
||||
|
<key>CFBundleIdentifier</key> |
||||
|
<string>com.soniox.voice</string> |
||||
|
<key>CFBundleInfoDictionaryVersion</key> |
||||
|
<string>6.0</string> |
||||
|
<key>CFBundleName</key> |
||||
|
<string>SonioxVoice</string> |
||||
|
<key>CFBundlePackageType</key> |
||||
|
<string>APPL</string> |
||||
|
<key>CFBundleShortVersionString</key> |
||||
|
<string>1.0.0</string> |
||||
|
<key>CFBundleVersion</key> |
||||
|
<string>1</string> |
||||
|
<key>LSMinimumSystemVersion</key> |
||||
|
<string>13.0</string> |
||||
|
<key>LSUIElement</key> |
||||
|
<true/> |
||||
|
<key>NSHighResolutionCapable</key> |
||||
|
<true/> |
||||
|
<key>NSMicrophoneUsageDescription</key> |
||||
|
<string>Soniox Voice به دسترسی میکروفون جهت ضبط صدا و تبدیل آن به متن نیاز دارد.</string> |
||||
|
<key>NSAccessibilityUsageDescription</key> |
||||
|
<string>Soniox Voice به دسترسی Accessibility جهت درج خودکار متن در برنامه فعال نیاز دارد.</string> |
||||
|
<key>NSAppTransportSecurity</key> |
||||
|
<dict> |
||||
|
<key>NSAllowsArbitraryLoads</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,121 @@ |
|||||
|
import sys |
||||
|
import os |
||||
|
import math |
||||
|
from PIL import Image, ImageDraw, ImageFilter |
||||
|
|
||||
|
def create_app_icon(output_dir): |
||||
|
os.makedirs(output_dir, exist_ok=True) |
||||
|
iconset_dir = os.path.join(output_dir, "AppIcon.iconset") |
||||
|
os.makedirs(iconset_dir, exist_ok=True) |
||||
|
|
||||
|
size = 1024 |
||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) |
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Background Squircle / Rounded rect with gradient |
||||
|
margin = 80 |
||||
|
rect = [margin, margin, size - margin, size - margin] |
||||
|
radius = 200 |
||||
|
|
||||
|
# Create base squircle mask |
||||
|
mask = Image.new("L", (size, size), 0) |
||||
|
mask_draw = ImageDraw.Draw(mask) |
||||
|
mask_draw.rounded_rectangle(rect, radius=radius, fill=255) |
||||
|
|
||||
|
# Render gradient |
||||
|
grad = Image.new("RGBA", (size, size)) |
||||
|
grad_draw = ImageDraw.Draw(grad) |
||||
|
|
||||
|
# Rich Purple / Blue / Cyber Teal gradient |
||||
|
for y in range(size): |
||||
|
ratio = y / size |
||||
|
r = int(79 + (124 - 79) * ratio) |
||||
|
g = int(70 + (58 - 70) * ratio) |
||||
|
b = int(229 + (237 - 229) * ratio) |
||||
|
grad_draw.line([(0, y), (size, y)], fill=(r, g, b, 255)) |
||||
|
|
||||
|
img.paste(grad, (0, 0), mask) |
||||
|
|
||||
|
# Draw Inner Glowing Waveform & Microphone |
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Mic Capsule |
||||
|
center_x = size // 2 |
||||
|
center_y = size // 2 - 40 |
||||
|
mic_w = 120 |
||||
|
mic_h = 240 |
||||
|
|
||||
|
# Glow behind mic |
||||
|
glow = Image.new("RGBA", (size, size), (0, 0, 0, 0)) |
||||
|
glow_draw = ImageDraw.Draw(glow) |
||||
|
glow_draw.rounded_rectangle([center_x - mic_w//2 - 20, center_y - mic_h//2 - 20, center_x + mic_w//2 + 20, center_y + mic_h//2 + 20], radius=80, fill=(255, 255, 255, 60)) |
||||
|
glow = glow.filter(ImageFilter.GaussianBlur(30)) |
||||
|
img.alpha_composite(glow) |
||||
|
|
||||
|
draw = ImageDraw.Draw(img) |
||||
|
|
||||
|
# Mic Body (Capsule) |
||||
|
draw.rounded_rectangle( |
||||
|
[center_x - mic_w//2, center_y - mic_h//2, center_x + mic_w//2, center_y + mic_h//2], |
||||
|
radius=60, |
||||
|
fill=(255, 255, 255, 250) |
||||
|
) |
||||
|
|
||||
|
# Mic Arc / Cradle |
||||
|
cradle_w = 220 |
||||
|
cradle_h = 200 |
||||
|
arc_top = center_y - 20 |
||||
|
draw.arc( |
||||
|
[center_x - cradle_w//2, arc_top, center_x + cradle_w//2, arc_top + cradle_h], |
||||
|
start=0, |
||||
|
end=180, |
||||
|
fill=(255, 255, 255, 240), |
||||
|
width=24 |
||||
|
) |
||||
|
|
||||
|
# Mic Stand / Stem |
||||
|
stem_top = arc_top + cradle_h |
||||
|
draw.line([(center_x, stem_top), (center_x, stem_top + 70)], fill=(255, 255, 255, 240), width=24) |
||||
|
# Mic Base |
||||
|
draw.line([(center_x - 80, stem_top + 70), (center_x + 80, stem_top + 70)], fill=(255, 255, 255, 240), width=24) |
||||
|
|
||||
|
# Sonic Sound Waves on sides |
||||
|
for side in [-1, 1]: |
||||
|
for i, r in enumerate([190, 260]): |
||||
|
wave_cx = center_x + side * 40 |
||||
|
wave_w = r * 2 |
||||
|
wave_h = r * 2 |
||||
|
start_ang = 300 if side == 1 else 120 |
||||
|
end_ang = 60 if side == 1 else 240 |
||||
|
draw.arc( |
||||
|
[wave_cx - wave_w//2, center_y - wave_h//2, wave_cx + wave_w//2, center_y + wave_h//2], |
||||
|
start=start_ang, |
||||
|
end=end_ang, |
||||
|
fill=(255, 255, 255, 180 - i * 60), |
||||
|
width=18 |
||||
|
) |
||||
|
|
||||
|
# Save standard sizes |
||||
|
sizes = [ |
||||
|
(16, "icon_16x16.png"), |
||||
|
(32, "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") |
||||
@ -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" |
||||
|
After Width: 128 | Height: 128 | Size: 10 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 16 | Height: 16 | Size: 686 B |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 256 | Height: 256 | Size: 23 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 32 | Height: 32 | Size: 1.9 KiB |
|
After Width: 64 | Height: 64 | Size: 4.5 KiB |
|
After Width: 512 | Height: 512 | Size: 47 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
|
After Width: 1024 | Height: 1024 | Size: 31 KiB |
@ -0,0 +1,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() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,165 @@ |
|||||
|
import Foundation |
||||
|
import AVFoundation |
||||
|
import CoreMedia |
||||
|
|
||||
|
public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate { |
||||
|
private var captureSession: AVCaptureSession? |
||||
|
private var audioOutput: AVCaptureAudioDataOutput? |
||||
|
private var audioConverter: AVAudioConverter? |
||||
|
private let targetFormat: AVAudioFormat |
||||
|
|
||||
|
private var pcmBuffer = Data() |
||||
|
private let lock = NSLock() |
||||
|
private let captureQueue = DispatchQueue(label: "com.soniox.audiocapture", qos: .userInteractive) |
||||
|
|
||||
|
public private(set) var isRecording = false |
||||
|
public var onAudioLevelUpdate: ((Float) -> Void)? |
||||
|
public var onAudioChunkAvailable: ((Data) -> Void)? |
||||
|
|
||||
|
public override init() { |
||||
|
self.targetFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false)! |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
public func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { |
||||
|
switch AVCaptureDevice.authorizationStatus(for: .audio) { |
||||
|
case .authorized: |
||||
|
completion(true) |
||||
|
case .notDetermined: |
||||
|
AVCaptureDevice.requestAccess(for: .audio) { granted in |
||||
|
DispatchQueue.main.async { |
||||
|
completion(granted) |
||||
|
} |
||||
|
} |
||||
|
case .denied, .restricted: |
||||
|
completion(false) |
||||
|
@unknown default: |
||||
|
completion(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func startRecording() throws { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if isRecording { return } |
||||
|
pcmBuffer.removeAll() |
||||
|
|
||||
|
guard let device = AVCaptureDevice.default(for: .audio) else { |
||||
|
throw NSError(domain: "AudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "میکروفونی یافت نشد"]) |
||||
|
} |
||||
|
|
||||
|
let session = AVCaptureSession() |
||||
|
let input = try AVCaptureDeviceInput(device: device) |
||||
|
|
||||
|
if session.canAddInput(input) { |
||||
|
session.addInput(input) |
||||
|
} |
||||
|
|
||||
|
let output = AVCaptureAudioDataOutput() |
||||
|
output.setSampleBufferDelegate(self, queue: captureQueue) |
||||
|
|
||||
|
if session.canAddOutput(output) { |
||||
|
session.addOutput(output) |
||||
|
} |
||||
|
|
||||
|
self.captureSession = session |
||||
|
self.audioOutput = output |
||||
|
|
||||
|
session.startRunning() |
||||
|
isRecording = true |
||||
|
print("AudioRecorder: Started recording with device:", device.localizedName) |
||||
|
} |
||||
|
|
||||
|
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { |
||||
|
guard isRecording else { return } |
||||
|
|
||||
|
guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) else { return } |
||||
|
let srcFormat = AVAudioFormat(cmAudioFormatDescription: formatDesc) |
||||
|
|
||||
|
if audioConverter == nil || audioConverter?.inputFormat != srcFormat { |
||||
|
audioConverter = AVAudioConverter(from: srcFormat, to: targetFormat) |
||||
|
} |
||||
|
guard let converter = self.audioConverter else { return } |
||||
|
|
||||
|
guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return } |
||||
|
let numSamples = CMSampleBufferGetNumSamples(sampleBuffer) |
||||
|
guard numSamples > 0 else { return } |
||||
|
|
||||
|
guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: srcFormat, frameCapacity: AVAudioFrameCount(numSamples)) else { return } |
||||
|
srcBuffer.frameLength = AVAudioFrameCount(numSamples) |
||||
|
|
||||
|
var lengthAtOffset = 0 |
||||
|
var totalLength = 0 |
||||
|
var dataPointer: UnsafeMutablePointer<Int8>? |
||||
|
|
||||
|
if CMBlockBufferGetDataPointer(blockBuffer, atOffset: 0, lengthAtOffsetOut: &lengthAtOffset, totalLengthOut: &totalLength, dataPointerOut: &dataPointer) == noErr, |
||||
|
let dataPtr = dataPointer { |
||||
|
if let floatData = srcBuffer.floatChannelData?[0] { |
||||
|
memcpy(floatData, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 4)) |
||||
|
} else if let int16Data = srcBuffer.int16ChannelData?[0] { |
||||
|
memcpy(int16Data, dataPtr, min(totalLength, Int(srcBuffer.frameLength) * 2)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let outCapacity = AVAudioFrameCount(Double(numSamples) * (16000.0 / srcFormat.sampleRate)) + 128 |
||||
|
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outCapacity) else { return } |
||||
|
|
||||
|
var error: NSError? |
||||
|
var haveData = true |
||||
|
let status = converter.convert(to: outBuffer, error: &error) { inNumPackets, outStatus in |
||||
|
if haveData { |
||||
|
haveData = false |
||||
|
outStatus.pointee = .haveData |
||||
|
return srcBuffer |
||||
|
} else { |
||||
|
outStatus.pointee = .noDataNow |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if status == .haveData || status == .inputRanDry { |
||||
|
let frameLen = Int(outBuffer.frameLength) |
||||
|
if frameLen > 0, let int16Ptr = outBuffer.int16ChannelData?[0] { |
||||
|
let bytesCount = frameLen * MemoryLayout<Int16>.size |
||||
|
let data = Data(bytes: int16Ptr, count: bytesCount) |
||||
|
|
||||
|
lock.lock() |
||||
|
pcmBuffer.append(data) |
||||
|
lock.unlock() |
||||
|
|
||||
|
// Stream live audio chunk to WebSocket immediately |
||||
|
onAudioChunkAvailable?(data) |
||||
|
|
||||
|
// Calculate RMS level for HUD |
||||
|
var sumSquare: Float = 0 |
||||
|
for i in 0..<frameLen { |
||||
|
let sample = Float(int16Ptr[i]) / 32768.0 |
||||
|
sumSquare += sample * sample |
||||
|
} |
||||
|
let rms = sqrt(sumSquare / Float(frameLen)) |
||||
|
let normalizedLevel = min(max(rms * 4.5, 0.0), 1.0) |
||||
|
|
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onAudioLevelUpdate?(normalizedLevel) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func stopRecording() -> Data { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if !isRecording { return pcmBuffer } |
||||
|
isRecording = false |
||||
|
|
||||
|
captureSession?.stopRunning() |
||||
|
captureSession = nil |
||||
|
audioOutput = nil |
||||
|
audioConverter = nil |
||||
|
|
||||
|
print("AudioRecorder: Stopped. Total PCM captured: \(pcmBuffer.count) bytes (\(Double(pcmBuffer.count)/32000.0) seconds)") |
||||
|
return pcmBuffer |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,261 @@ |
|||||
|
import Cocoa |
||||
|
import Carbon |
||||
|
import ApplicationServices |
||||
|
|
||||
|
public enum DictationMode: String, CaseIterable { |
||||
|
case pushToTalk = "pushToTalk" // Hold to record, release to transcribe |
||||
|
case toggle = "toggle" // Press once to start, press again to stop |
||||
|
|
||||
|
public var localizedTitle: String { |
||||
|
switch self { |
||||
|
case .pushToTalk: |
||||
|
return "نگهداشتن برای صحبت (Hold to Talk)" |
||||
|
case .toggle: |
||||
|
return "فشردن برای شروع / توقف (Toggle)" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public enum HotkeyPreset: String, CaseIterable { |
||||
|
case option = "Option (⌥ نگهداشتن)" |
||||
|
case capsLock = "Caps Lock" |
||||
|
case controlSpace = "Control + Space" |
||||
|
case optionSpace = "Option + Space" |
||||
|
case cmdShiftSpace = "Cmd + Shift + Space" |
||||
|
case f8 = "F8" |
||||
|
case f5 = "F5" |
||||
|
|
||||
|
public var keyCode: UInt32 { |
||||
|
switch self { |
||||
|
case .option: |
||||
|
return UInt32(kVK_Option) // 58 |
||||
|
case .capsLock: |
||||
|
return UInt32(kVK_CapsLock) // 57 |
||||
|
case .controlSpace, .optionSpace, .cmdShiftSpace: |
||||
|
return UInt32(kVK_Space) |
||||
|
case .f8: |
||||
|
return UInt32(kVK_F8) |
||||
|
case .f5: |
||||
|
return UInt32(kVK_F5) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public var carbonModifiers: UInt32 { |
||||
|
switch self { |
||||
|
case .option, .capsLock: |
||||
|
return 0 |
||||
|
case .controlSpace: |
||||
|
return UInt32(controlKey) |
||||
|
case .optionSpace: |
||||
|
return UInt32(optionKey) |
||||
|
case .cmdShiftSpace: |
||||
|
return UInt32(cmdKey | shiftKey) |
||||
|
case .f8, .f5: |
||||
|
return 0 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public final class HotkeyManager { |
||||
|
public static let shared = HotkeyManager() |
||||
|
|
||||
|
public var onHotkeyPressed: (() -> Void)? |
||||
|
public var onHotkeyReleased: (() -> Void)? |
||||
|
|
||||
|
private var hotKeyRef: EventHotKeyRef? |
||||
|
private var eventHandlerRef: EventHandlerRef? |
||||
|
private var eventTapPort: CFMachPort? |
||||
|
private var runLoopSource: CFRunLoopSource? |
||||
|
private var globalMonitor: Any? |
||||
|
|
||||
|
private var isOptionPhysicallyDown = false |
||||
|
private var isCapsLockPhysicallyDown = false |
||||
|
private var isKeyDown = false |
||||
|
|
||||
|
public var currentPreset: HotkeyPreset { |
||||
|
get { |
||||
|
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue |
||||
|
return HotkeyPreset(rawValue: val) ?? .option |
||||
|
} |
||||
|
set { |
||||
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") |
||||
|
registerHotkeys() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public var currentMode: DictationMode { |
||||
|
get { |
||||
|
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue |
||||
|
return DictationMode(rawValue: val) ?? .pushToTalk |
||||
|
} |
||||
|
set { |
||||
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode") |
||||
|
registerHotkeys() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private init() {} |
||||
|
|
||||
|
public func registerHotkeys() { |
||||
|
unregisterHotkeys() |
||||
|
|
||||
|
let preset = currentPreset |
||||
|
print("Registering Hotkey for preset:", preset.rawValue, "mode:", currentMode.rawValue) |
||||
|
|
||||
|
// 1. Carbon HotKey for multi-key combos (Control+Space, etc.) |
||||
|
if preset != .option && preset != .capsLock { |
||||
|
var eventTypes = [ |
||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), |
||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) |
||||
|
] |
||||
|
|
||||
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
||||
|
let handlerCallback: EventHandlerUPP = { (_, eventRef, userData) -> OSStatus in |
||||
|
guard let eventRef = eventRef, let userData = userData else { return noErr } |
||||
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue() |
||||
|
|
||||
|
let kind = GetEventKind(eventRef) |
||||
|
if kind == UInt32(kEventHotKeyPressed) { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} |
||||
|
} else if kind == UInt32(kEventHotKeyReleased) { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
return noErr |
||||
|
} |
||||
|
|
||||
|
InstallEventHandler(GetApplicationEventTarget(), handlerCallback, 2, &eventTypes, selfPtr, &eventHandlerRef) |
||||
|
|
||||
|
let hotKeyID = EventHotKeyID(signature: OSType(0x534F4E58), id: 1) |
||||
|
RegisterEventHotKey( |
||||
|
preset.keyCode, |
||||
|
preset.carbonModifiers, |
||||
|
hotKeyID, |
||||
|
GetApplicationEventTarget(), |
||||
|
0, |
||||
|
&hotKeyRef |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 2. Global Event Tap for single modifier keys (Option, CapsLock) |
||||
|
setupEventTap() |
||||
|
|
||||
|
// 3. Secondary NSEvent Global Monitor as backup |
||||
|
setupGlobalMonitor() |
||||
|
} |
||||
|
|
||||
|
private func setupEventTap() { |
||||
|
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) |
||||
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
||||
|
|
||||
|
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in |
||||
|
guard let refcon = refcon else { return Unmanaged.passRetained(event) } |
||||
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon).takeUnretainedValue() |
||||
|
|
||||
|
let flags = event.flags.rawValue |
||||
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) |
||||
|
|
||||
|
// 1. Option Key (Hold / Push-to-Talk) |
||||
|
if manager.currentPreset == .option { |
||||
|
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 |
||||
|
if isAlt != manager.isOptionPhysicallyDown { |
||||
|
manager.isOptionPhysicallyDown = isAlt |
||||
|
DispatchQueue.main.async { |
||||
|
if isAlt { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} else { |
||||
|
if manager.currentMode == .pushToTalk { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 2. CapsLock Key |
||||
|
else if manager.currentPreset == .capsLock { |
||||
|
if keyCode == 57 { |
||||
|
if !manager.isCapsLockPhysicallyDown { |
||||
|
manager.isCapsLockPhysicallyDown = true |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyPressed?() |
||||
|
} |
||||
|
} else { |
||||
|
manager.isCapsLockPhysicallyDown = false |
||||
|
if manager.currentMode == .pushToTalk { |
||||
|
DispatchQueue.main.async { |
||||
|
manager.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Unmanaged.passRetained(event) |
||||
|
} |
||||
|
|
||||
|
if let tap = CGEvent.tapCreate( |
||||
|
tap: .cghidEventTap, |
||||
|
place: .headInsertEventTap, |
||||
|
options: .defaultTap, |
||||
|
eventsOfInterest: CGEventMask(mask), |
||||
|
callback: callback, |
||||
|
userInfo: selfPtr |
||||
|
) { |
||||
|
self.eventTapPort = tap |
||||
|
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) |
||||
|
self.runLoopSource = source |
||||
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) |
||||
|
CGEvent.tapEnable(tap: tap, enable: true) |
||||
|
print("CGEventTap created and enabled successfully.") |
||||
|
} else { |
||||
|
print("CGEventTap creation failed. Falling back to NSEvent global monitor.") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func setupGlobalMonitor() { |
||||
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// Only use as fallback if event tap is inactive |
||||
|
if self.eventTapPort == nil { |
||||
|
if self.currentPreset == .option { |
||||
|
let isAlt = event.modifierFlags.contains(.option) |
||||
|
if isAlt != self.isOptionPhysicallyDown { |
||||
|
self.isOptionPhysicallyDown = isAlt |
||||
|
if isAlt { |
||||
|
self.onHotkeyPressed?() |
||||
|
} else if self.currentMode == .pushToTalk { |
||||
|
self.onHotkeyReleased?() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func unregisterHotkeys() { |
||||
|
if let ref = hotKeyRef { |
||||
|
UnregisterEventHotKey(ref) |
||||
|
hotKeyRef = nil |
||||
|
} |
||||
|
if let handler = eventHandlerRef { |
||||
|
RemoveEventHandler(handler) |
||||
|
eventHandlerRef = nil |
||||
|
} |
||||
|
if let tap = eventTapPort, let source = runLoopSource { |
||||
|
CGEvent.tapEnable(tap: tap, enable: false) |
||||
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) |
||||
|
self.eventTapPort = nil |
||||
|
self.runLoopSource = nil |
||||
|
} |
||||
|
if let mon = globalMonitor { |
||||
|
NSEvent.removeMonitor(mon) |
||||
|
self.globalMonitor = nil |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,321 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
public final class SonioxLiveSession { |
||||
|
private let primaryWsBase = "wss://translate.compare.soniox.com/compare/api/compare-websocket" |
||||
|
private let fallbackWsBase = "wss://stt.compare.soniox.com/compare/api/compare-websocket" |
||||
|
private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" |
||||
|
private let origin = "https://translate.compare.soniox.com" |
||||
|
|
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private var urlSession: URLSession? |
||||
|
private var isFinalizing = false |
||||
|
private var isClosed = false |
||||
|
private var isConnected = false |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
// Transcripts tracking |
||||
|
private var committedFinalTokens: [String] = [] |
||||
|
private var currentNonFinalTokens: [String] = [] |
||||
|
|
||||
|
public var onPartialText: ((String) -> Void)? |
||||
|
public var onFinalResult: ((Result<String, Error>) -> Void)? |
||||
|
public var onConnectionStateChanged: ((Bool) -> Void)? |
||||
|
|
||||
|
public var isReady: Bool { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
return isConnected && !isClosed && !isFinalizing |
||||
|
} |
||||
|
|
||||
|
public init(languageHints: [String] = ["fa", "en", "ar"]) { |
||||
|
let hints = languageHints.map { "language_hints=\($0)" }.joined(separator: "&") |
||||
|
let urlStr = "\(primaryWsBase)?\(hints)&enable_speaker_diarization=false&enable_language_identification=true&enable_endpoint_detection=false&providers=soniox" |
||||
|
guard let url = URL(string: urlStr) else { return } |
||||
|
|
||||
|
var request = URLRequest(url: url) |
||||
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent") |
||||
|
request.setValue(origin, forHTTPHeaderField: "Origin") |
||||
|
request.timeoutInterval = 20.0 |
||||
|
|
||||
|
let config = URLSessionConfiguration.default |
||||
|
config.waitsForConnectivity = true |
||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData |
||||
|
|
||||
|
let session = URLSession(configuration: config) |
||||
|
self.urlSession = session |
||||
|
let task = session.webSocketTask(with: request) |
||||
|
self.webSocketTask = task |
||||
|
task.resume() |
||||
|
|
||||
|
// Fast ping to verify connection |
||||
|
task.sendPing { [weak self] error in |
||||
|
guard let self = self else { return } |
||||
|
self.lock.lock() |
||||
|
if error == nil && !self.isClosed { |
||||
|
self.isConnected = true |
||||
|
self.lock.unlock() |
||||
|
self.onConnectionStateChanged?(true) |
||||
|
print("SonioxLiveSession: WebSocket connected successfully.") |
||||
|
} else { |
||||
|
self.lock.unlock() |
||||
|
if let error = error { |
||||
|
print("SonioxLiveSession: Ping failed:", error) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
startReceiving() |
||||
|
} |
||||
|
|
||||
|
public func sendAudioChunk(_ data: Data) { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
guard !isFinalizing, !isClosed, let task = webSocketTask else { return } |
||||
|
|
||||
|
let message = URLSessionWebSocketTask.Message.data(data) |
||||
|
task.send(message) { error in |
||||
|
if let error = error { |
||||
|
print("Error streaming audio chunk:", error) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func finalizeStream() { |
||||
|
lock.lock() |
||||
|
guard !isFinalizing, !isClosed, let task = webSocketTask else { |
||||
|
lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
isFinalizing = true |
||||
|
lock.unlock() |
||||
|
|
||||
|
print("SonioxLiveSession: Sending finalize packet...") |
||||
|
let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}") |
||||
|
task.send(finalizeMsg) { [weak self] error in |
||||
|
if let error = error { |
||||
|
print("Error sending finalize:", error) |
||||
|
self?.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Safety timeout fallback: finalize must complete within 800ms |
||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.80) { [weak self] in |
||||
|
self?.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func startReceiving() { |
||||
|
guard let task = webSocketTask else { return } |
||||
|
task.receive { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
self.lock.lock() |
||||
|
if self.isClosed { |
||||
|
self.lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
self.lock.unlock() |
||||
|
|
||||
|
switch result { |
||||
|
case .success(let message): |
||||
|
var textReceived: String? |
||||
|
switch message { |
||||
|
case .string(let str): |
||||
|
textReceived = str |
||||
|
case .data(let data): |
||||
|
textReceived = String(data: data, encoding: .utf8) |
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
if let text = textReceived, let jsonData = text.data(using: .utf8) { |
||||
|
self.parseMessage(jsonData) |
||||
|
} |
||||
|
self.startReceiving() |
||||
|
|
||||
|
case .failure(let error): |
||||
|
print("WebSocket receive status:", error) |
||||
|
self.completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func parseMessage(_ data: Data) { |
||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } |
||||
|
|
||||
|
var gotFin = false |
||||
|
var newFinals: [String] = [] |
||||
|
var newNonFinals: [String] = [] |
||||
|
|
||||
|
if let type = json["type"] as? String, type == "data", |
||||
|
let parts = json["parts"] as? [[String: Any]] { |
||||
|
for part in parts { |
||||
|
let transStatus = part["translation_status"] as? String |
||||
|
if transStatus == "translation" { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
let pText = part["text"] as? String ?? "" |
||||
|
let isFinal = part["is_final"] as? Bool ?? false |
||||
|
|
||||
|
if pText.contains("<fin>") { |
||||
|
gotFin = true |
||||
|
let clean = pText.replacingOccurrences(of: "<fin>", with: "") |
||||
|
if !clean.isEmpty { |
||||
|
newFinals.append(clean) |
||||
|
} |
||||
|
} else if isFinal { |
||||
|
if !pText.isEmpty { |
||||
|
newFinals.append(pText) |
||||
|
} |
||||
|
} else { |
||||
|
if !pText.isEmpty { |
||||
|
newNonFinals.append(pText) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
lock.lock() |
||||
|
if !newFinals.isEmpty { |
||||
|
committedFinalTokens.append(contentsOf: newFinals) |
||||
|
} |
||||
|
currentNonFinalTokens = newNonFinals |
||||
|
|
||||
|
let fullCommitted = committedFinalTokens.joined() |
||||
|
let fullNonFinal = currentNonFinalTokens.joined() |
||||
|
let combined = fullCommitted + fullNonFinal |
||||
|
lock.unlock() |
||||
|
|
||||
|
if !combined.isEmpty { |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.onPartialText?(combined) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let sessionEnded = json["session_ended"] as? Bool ?? false |
||||
|
let sessionDone = (json["type"] as? String) == "session_done" |
||||
|
|
||||
|
if gotFin || sessionEnded || sessionDone { |
||||
|
completeWithCurrentText() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func completeWithCurrentText() { |
||||
|
lock.lock() |
||||
|
if isClosed { |
||||
|
lock.unlock() |
||||
|
return |
||||
|
} |
||||
|
isClosed = true |
||||
|
let fullCommitted = committedFinalTokens.joined() |
||||
|
let fullNonFinal = currentNonFinalTokens.joined() |
||||
|
let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal) |
||||
|
let cleaned = sanitizeText(rawCombined) |
||||
|
|
||||
|
let cb = onFinalResult |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
urlSession = nil |
||||
|
lock.unlock() |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
cb?(.success(cleaned)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func cancel() { |
||||
|
lock.lock() |
||||
|
isClosed = true |
||||
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
|
webSocketTask = nil |
||||
|
urlSession = nil |
||||
|
lock.unlock() |
||||
|
} |
||||
|
|
||||
|
private func sanitizeText(_ text: String) -> String { |
||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
if trimmed.isEmpty { return "" } |
||||
|
|
||||
|
let words = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
||||
|
if words.isEmpty { return "" } |
||||
|
|
||||
|
let faPattern = "[\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFF]" |
||||
|
let enPattern = "[a-zA-Z]" |
||||
|
|
||||
|
func matches(_ pattern: String, in str: String) -> Bool { |
||||
|
return str.range(of: pattern, options: .regularExpression) != nil |
||||
|
} |
||||
|
|
||||
|
var faCount = 0 |
||||
|
var enCount = 0 |
||||
|
for w in words { |
||||
|
if matches(faPattern, in: w) { faCount += 1 } |
||||
|
if matches(enPattern, in: w) { enCount += 1 } |
||||
|
} |
||||
|
|
||||
|
let total = faCount + enCount |
||||
|
if total == 0 { return trimmed } |
||||
|
|
||||
|
let faRatio = Double(faCount) / Double(total) |
||||
|
var cleaned: [String] = [] |
||||
|
|
||||
|
let stopWords: Set<String> = ["sex", "from", "no", "oh", "you", "all", "know", "that", "god", "loves", "men", "for", "day", "night", "mankind", "wanted", "moments"] |
||||
|
|
||||
|
if faRatio >= 0.25 { |
||||
|
for w in words { |
||||
|
if matches(enPattern, in: w) && !matches(faPattern, in: w) { |
||||
|
let cleanW = w.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".,!?:;،؛؟\"'()[]{}«»-–—")) |
||||
|
if stopWords.contains(cleanW) { continue } |
||||
|
if faRatio >= 0.70 { continue } |
||||
|
} |
||||
|
cleaned.append(w) |
||||
|
} |
||||
|
} else { |
||||
|
cleaned = words |
||||
|
} |
||||
|
|
||||
|
return cleaned.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Pre-warms and pools active WebSocket sessions for 0ms start latency |
||||
|
public final class SonioxSessionPool { |
||||
|
public static let shared = SonioxSessionPool() |
||||
|
|
||||
|
private var prewarmedSession: SonioxLiveSession? |
||||
|
private let lock = NSLock() |
||||
|
|
||||
|
private init() { |
||||
|
prewarmNextSession() |
||||
|
} |
||||
|
|
||||
|
public func prewarmNextSession() { |
||||
|
lock.lock() |
||||
|
defer { lock.unlock() } |
||||
|
|
||||
|
if let existing = prewarmedSession, existing.isReady { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let session = SonioxLiveSession() |
||||
|
self.prewarmedSession = session |
||||
|
} |
||||
|
|
||||
|
public func acquireSession() -> SonioxLiveSession { |
||||
|
lock.lock() |
||||
|
let session = prewarmedSession |
||||
|
prewarmedSession = nil |
||||
|
lock.unlock() |
||||
|
|
||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { [weak self] in |
||||
|
self?.prewarmNextSession() |
||||
|
} |
||||
|
|
||||
|
if let session = session, session.isReady { |
||||
|
return session |
||||
|
} |
||||
|
|
||||
|
return SonioxLiveSession() |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
import Cocoa |
||||
|
|
||||
|
public final class StatusBarController { |
||||
|
private var statusItem: NSStatusItem? |
||||
|
public var onToggleRecording: (() -> Void)? |
||||
|
|
||||
|
public init() { |
||||
|
setupStatusItem() |
||||
|
} |
||||
|
|
||||
|
private func setupStatusItem() { |
||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) |
||||
|
updateIcon(state: .idle) |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
public enum State { |
||||
|
case idle |
||||
|
case recording |
||||
|
case transcribing |
||||
|
} |
||||
|
|
||||
|
public func updateIcon(state: State) { |
||||
|
guard let button = statusItem?.button else { return } |
||||
|
|
||||
|
switch state { |
||||
|
case .idle: |
||||
|
if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice") { |
||||
|
image.isTemplate = true |
||||
|
button.image = image |
||||
|
} |
||||
|
button.toolTip = "Soniox Voice (آماده)" |
||||
|
case .recording: |
||||
|
if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") { |
||||
|
image.isTemplate = false |
||||
|
button.image = image |
||||
|
button.contentTintColor = NSColor.systemRed |
||||
|
} |
||||
|
button.toolTip = "در حال ضبط صدا..." |
||||
|
case .transcribing: |
||||
|
if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing") { |
||||
|
image.isTemplate = false |
||||
|
button.image = image |
||||
|
button.contentTintColor = NSColor.systemOrange |
||||
|
} |
||||
|
button.toolTip = "در حال تبدیل به متن..." |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func buildMenu(isRecording: Bool = false) { |
||||
|
let menu = NSMenu() |
||||
|
menu.autoenablesItems = false |
||||
|
|
||||
|
// 1. Record Action |
||||
|
let recordTitle = isRecording ? "⏹️ توقف ضبط و درج متن" : "🎙️ شروع ضبط صدا (\(HotkeyManager.shared.currentPreset.rawValue))" |
||||
|
let recordItem = NSMenuItem(title: recordTitle, action: #selector(toggleRecordAction), keyEquivalent: "") |
||||
|
recordItem.target = self |
||||
|
menu.addItem(recordItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 2. Mode Submenu |
||||
|
let modeMenu = NSMenu() |
||||
|
for mode in DictationMode.allCases { |
||||
|
let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "") |
||||
|
item.target = self |
||||
|
item.representedObject = mode |
||||
|
item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off |
||||
|
modeMenu.addItem(item) |
||||
|
} |
||||
|
let modeMenuItem = NSMenuItem(title: "⚙️ حالت کارکرد", action: nil, keyEquivalent: "") |
||||
|
modeMenuItem.submenu = modeMenu |
||||
|
menu.addItem(modeMenuItem) |
||||
|
|
||||
|
// 3. Hotkey Submenu |
||||
|
let hotkeyMenu = NSMenu() |
||||
|
for preset in HotkeyPreset.allCases { |
||||
|
let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "") |
||||
|
item.target = self |
||||
|
item.representedObject = preset |
||||
|
item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off |
||||
|
hotkeyMenu.addItem(item) |
||||
|
} |
||||
|
let hotkeyMenuItem = NSMenuItem(title: "⌨️ کلید میانبر (Hotkey)", action: nil, keyEquivalent: "") |
||||
|
hotkeyMenuItem.submenu = hotkeyMenu |
||||
|
menu.addItem(hotkeyMenuItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 4. Sound Effects Toggle |
||||
|
let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") |
||||
|
let soundItem = NSMenuItem(title: "🔊 پخش افکت صوتی", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "") |
||||
|
soundItem.target = self |
||||
|
soundItem.state = soundsEnabled ? .on : .off |
||||
|
menu.addItem(soundItem) |
||||
|
|
||||
|
// 5. Accessibility Permission Check |
||||
|
let isAxTrusted = AXIsProcessTrusted() |
||||
|
let axTitle = isAxTrusted ? "✅ دسترسی Accessibility فعال است" : "🔑 اعطای دسترسی Accessibility..." |
||||
|
let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "") |
||||
|
axItem.target = self |
||||
|
menu.addItem(axItem) |
||||
|
|
||||
|
// 6. Launch at Login |
||||
|
let launchLogin = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") |
||||
|
let launchItem = NSMenuItem(title: "🚀 اجرا هنگام بالا آمدن سیستم", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "") |
||||
|
launchItem.target = self |
||||
|
launchItem.state = launchLogin ? .on : .off |
||||
|
menu.addItem(launchItem) |
||||
|
|
||||
|
menu.addItem(NSMenuItem.separator()) |
||||
|
|
||||
|
// 7. About & Quit |
||||
|
let aboutItem = NSMenuItem(title: "ℹ️ درباره Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") |
||||
|
aboutItem.target = self |
||||
|
menu.addItem(aboutItem) |
||||
|
|
||||
|
let quitItem = NSMenuItem(title: "❌ خروج", action: #selector(quitAction), keyEquivalent: "q") |
||||
|
quitItem.target = self |
||||
|
menu.addItem(quitItem) |
||||
|
|
||||
|
statusItem?.menu = menu |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleRecordAction() { |
||||
|
onToggleRecording?() |
||||
|
} |
||||
|
|
||||
|
@objc private func selectModeAction(_ sender: NSMenuItem) { |
||||
|
if let mode = sender.representedObject as? DictationMode { |
||||
|
HotkeyManager.shared.currentMode = mode |
||||
|
buildMenu() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func selectHotkeyAction(_ sender: NSMenuItem) { |
||||
|
if let preset = sender.representedObject as? HotkeyPreset { |
||||
|
HotkeyManager.shared.currentPreset = preset |
||||
|
buildMenu() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleSoundsAction(_ sender: NSMenuItem) { |
||||
|
let current = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") |
||||
|
UserDefaults.standard.set(!current, forKey: "SonioxPlaySounds") |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
@objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { |
||||
|
let current = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") |
||||
|
UserDefaults.standard.set(!current, forKey: "SonioxLaunchAtLogin") |
||||
|
buildMenu() |
||||
|
} |
||||
|
|
||||
|
@objc private func openAccessibilitySettings() { |
||||
|
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! |
||||
|
NSWorkspace.shared.open(url) |
||||
|
} |
||||
|
|
||||
|
@objc private func aboutAction() { |
||||
|
let alert = NSAlert() |
||||
|
alert.messageText = "Soniox Voice v1.0" |
||||
|
alert.informativeText = "تبدیل بلادرنگ گفتار به متن فارسی و انگلیسی با موتور ابری فوق سریع Soniox.\n\nتوسعه یافته برای macOS." |
||||
|
alert.alertStyle = .informational |
||||
|
alert.addButton(withTitle: "باشه") |
||||
|
alert.runModal() |
||||
|
} |
||||
|
|
||||
|
@objc private func quitAction() { |
||||
|
NSApplication.shared.terminate(nil) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
import Cocoa |
||||
|
|
||||
|
let app = NSApplication.shared |
||||
|
let delegate = AppDelegate() |
||||
|
app.delegate = delegate |
||||
|
app.setActivationPolicy(.accessory) |
||||
|
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) |
||||
@ -0,0 +1,42 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func inspectApp(named targetName: String) { |
||||
|
guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.localizedName == targetName }) else { |
||||
|
print("App \(targetName) not running") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("\n--- Inspecting \(targetName) (PID: \(app.processIdentifier)) ---") |
||||
|
let appElem = AXUIElementCreateApplication(app.processIdentifier) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
||||
|
|
||||
|
var focusedElemObj: CFTypeRef? |
||||
|
let err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) |
||||
|
print("kAXFocusedUIElementAttribute error:", err.rawValue) |
||||
|
|
||||
|
if err == .success, let elem = focusedElemObj { |
||||
|
let axElem = elem as! AXUIElement |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
print("Role:", roleObj ?? "none") |
||||
|
|
||||
|
var valObj: CFTypeRef? |
||||
|
let valErr = AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj) |
||||
|
print("Value err:", valErr.rawValue, "Value:", valObj ?? "none") |
||||
|
|
||||
|
var rangeObj: CFTypeRef? |
||||
|
let rangeErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) |
||||
|
if rangeErr == .success, let axRange = rangeObj { |
||||
|
var range = CFRange() |
||||
|
if AXValueGetValue(axRange as! AXValue, .cfRange, &range) { |
||||
|
print("Cursor: loc=\(range.location), len=\(range.length)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
inspectApp(named: "Telegram") |
||||
|
inspectApp(named: "Obsidian") |
||||
|
inspectApp(named: "firefox") |
||||
@ -0,0 +1,107 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
// Comprehensive recursive search for any element with AXFocused == true |
||||
|
func findActiveFocusedElement(_ elem: AXUIElement) -> AXUIElement? { |
||||
|
var isFocusedObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &isFocusedObj) == .success, |
||||
|
let isFocused = isFocusedObj as? Bool, isFocused { |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
let role = (roleObj as? String) ?? "" |
||||
|
if role != "AXWindow" && role != "AXApplication" && role != "AXGroup" && role != "AXSplitGroup" && role != "AXScrollArea" { |
||||
|
return elem |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var childrenObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success, |
||||
|
let children = childrenObj as? [AXUIElement] { |
||||
|
for child in children { |
||||
|
if let found = findActiveFocusedElement(child) { |
||||
|
return found |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func getFocusedTextInfo() -> (String, String, Int, Int)? { |
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil } |
||||
|
let appName = frontApp.localizedName ?? "App" |
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
|
||||
|
// 1. Try systemWide |
||||
|
let sysWide = AXUIElementCreateSystemWide() |
||||
|
var focusedElemObj: CFTypeRef? |
||||
|
var targetElem: AXUIElement? |
||||
|
|
||||
|
if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) == .success, |
||||
|
let obj = focusedElemObj { |
||||
|
targetElem = (obj as! AXUIElement) |
||||
|
} |
||||
|
|
||||
|
// 2. Try App focused element |
||||
|
if targetElem == nil { |
||||
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) == .success, |
||||
|
let obj = focusedElemObj { |
||||
|
targetElem = (obj as! AXUIElement) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 3. Try Recursive search in app tree |
||||
|
if targetElem == nil { |
||||
|
targetElem = findActiveFocusedElement(appElem) |
||||
|
} |
||||
|
|
||||
|
guard let elem = targetElem else { return nil } |
||||
|
|
||||
|
// Extract text |
||||
|
var text = "" |
||||
|
var valObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success, |
||||
|
let val = valObj { |
||||
|
if let str = val as? String { |
||||
|
text = str |
||||
|
} else if let attrStr = val as? NSAttributedString { |
||||
|
text = attrStr.string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Try parameterized string for range |
||||
|
if text.isEmpty { |
||||
|
var countObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countObj) == .success, |
||||
|
let count = countObj as? Int, count > 0 { |
||||
|
var range = CFRange(location: 0, length: count) |
||||
|
if let axRange = AXValueCreate(.cfRange, &range) { |
||||
|
var strObj: CFTypeRef? |
||||
|
if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &strObj) == .success, |
||||
|
let str = strObj as? String { |
||||
|
text = str |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Cursor & Selection |
||||
|
var cursor = text.count |
||||
|
var selLen = 0 |
||||
|
var rangeObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) == .success, |
||||
|
let axRange = rangeObj { |
||||
|
var range = CFRange() |
||||
|
if AXValueGetValue(axRange as! AXValue, .cfRange, &range) { |
||||
|
cursor = range.location |
||||
|
selLen = range.length |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return (appName, text, cursor, selLen) |
||||
|
} |
||||
|
|
||||
|
if let (app, text, cursor, selLen) = getFocusedTextInfo() { |
||||
|
print("Found! App: \(app), Text: '\(text)', Cursor: \(cursor), SelLen: \(selLen)") |
||||
|
} else { |
||||
|
print("No focused text element found") |
||||
|
} |
||||
@ -0,0 +1,110 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func extractTextAndCursor(from elem: AXUIElement) -> (String, Int, Int)? { |
||||
|
var roleRef: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef) |
||||
|
let role = (roleRef as? String) ?? "" |
||||
|
|
||||
|
var currentText = "" |
||||
|
var valueRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valueRef) == .success, |
||||
|
let val = valueRef { |
||||
|
if let str = val as? String { |
||||
|
currentText = str |
||||
|
} else if let attrStr = val as? NSAttributedString { |
||||
|
currentText = attrStr.string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if currentText.isEmpty { |
||||
|
var countRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXNumberOfCharactersAttribute as CFString, &countRef) == .success, |
||||
|
let count = countRef as? Int, count > 0 { |
||||
|
var range = CFRange(location: 0, length: count) |
||||
|
if let axRange = AXValueCreate(.cfRange, &range) { |
||||
|
var stringRef: CFTypeRef? |
||||
|
if AXUIElementCopyParameterizedAttributeValue(elem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &stringRef) == .success, |
||||
|
let str = stringRef as? String { |
||||
|
currentText = str |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
var cursor = currentText.count |
||||
|
var selLen = 0 |
||||
|
var rangeRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success, |
||||
|
let val = rangeRef { |
||||
|
var cfRange = CFRange() |
||||
|
if AXValueGetValue(val as! AXValue, .cfRange, &cfRange) { |
||||
|
cursor = cfRange.location |
||||
|
selLen = cfRange.length |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if !currentText.isEmpty || role == "AXTextField" || role == "AXTextArea" || role == "AXSearchField" || rangeRef != nil { |
||||
|
return (currentText, cursor, selLen) |
||||
|
} |
||||
|
|
||||
|
// Check focused child |
||||
|
var focusedChildRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXFocusedUIElementAttribute as CFString, &focusedChildRef) == .success, |
||||
|
let child = focusedChildRef { |
||||
|
if let res = extractTextAndCursor(from: child as! AXUIElement) { |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check children |
||||
|
var childrenRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenRef) == .success, |
||||
|
let children = childrenRef as? [AXUIElement] { |
||||
|
for child in children { |
||||
|
var isFocusedRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(child, kAXFocusedAttribute as CFString, &isFocusedRef) == .success, |
||||
|
let isFoc = isFocusedRef as? Bool, isFoc { |
||||
|
if let res = extractTextAndCursor(from: child) { |
||||
|
return res |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func testFullAX() { |
||||
|
let sysWide = AXUIElementCreateSystemWide() |
||||
|
AXUIElementSetAttributeValue(sysWide, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) |
||||
|
AXUIElementSetAttributeValue(sysWide, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
||||
|
|
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } |
||||
|
print("Front App:", frontApp.localizedName ?? "") |
||||
|
|
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
||||
|
|
||||
|
var focusedElem: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedElem) == .success, |
||||
|
let elem = focusedElem { |
||||
|
if let (text, cursor, sel) = extractTextAndCursor(from: elem as! AXUIElement) { |
||||
|
print("Extracted from sysWide -> Text: '\(text)', Cursor: \(cursor), Sel: \(sel)") |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElem) == .success, |
||||
|
let elem = focusedElem { |
||||
|
if let (text, cursor, sel) = extractTextAndCursor(from: elem as! AXUIElement) { |
||||
|
print("Extracted from appElem -> Text: '\(text)', Cursor: \(cursor), Sel: \(sel)") |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
print("Could not extract text") |
||||
|
} |
||||
|
|
||||
|
testFullAX() |
||||
@ -0,0 +1,45 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func inspect() { |
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { |
||||
|
print("No front app") |
||||
|
return |
||||
|
} |
||||
|
print("Front App:", frontApp.localizedName ?? "", "PID:", frontApp.processIdentifier) |
||||
|
|
||||
|
let trusted = AXIsProcessTrusted() |
||||
|
print("AXIsProcessTrusted:", trusted) |
||||
|
|
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
var focusedElemObj: CFTypeRef? |
||||
|
let err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedElemObj) |
||||
|
print("kAXFocusedUIElement error:", err.rawValue) |
||||
|
|
||||
|
if err == .success, let elem = focusedElemObj { |
||||
|
let axElem = elem as! AXUIElement |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
print("Role:", roleObj ?? "none") |
||||
|
|
||||
|
var valObj: CFTypeRef? |
||||
|
let valErr = AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj) |
||||
|
print("Value err:", valErr.rawValue, "Value:", valObj ?? "nil") |
||||
|
|
||||
|
var selectedTextObj: CFTypeRef? |
||||
|
let selTxtErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextAttribute as CFString, &selectedTextObj) |
||||
|
print("SelectedText err:", selTxtErr.rawValue, "SelectedText:", selectedTextObj ?? "nil") |
||||
|
|
||||
|
var rangeObj: CFTypeRef? |
||||
|
let rangeErr = AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeObj) |
||||
|
print("Range err:", rangeErr.rawValue) |
||||
|
if rangeErr == .success, let axRange = rangeObj { |
||||
|
var range = CFRange() |
||||
|
if AXValueGetValue(axRange as! AXValue, .cfRange, &range) { |
||||
|
print("CFRange: loc=\(range.location), len=\(range.length)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
inspect() |
||||
@ -0,0 +1,45 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func getFocusedElement() -> AXUIElement? { |
||||
|
// 1. System wide |
||||
|
let sysWide = AXUIElementCreateSystemWide() |
||||
|
var sysFocusedObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &sysFocusedObj) == .success, |
||||
|
let obj = sysFocusedObj { |
||||
|
return (obj as! AXUIElement) |
||||
|
} |
||||
|
|
||||
|
// 2. Frontmost App |
||||
|
if let frontApp = NSWorkspace.shared.frontmostApplication { |
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
var appFocusedObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &appFocusedObj) == .success, |
||||
|
let obj = appFocusedObj { |
||||
|
return (obj as! AXUIElement) |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func inspectElement(_ elem: AXUIElement) { |
||||
|
var attrNamesObj: CFArray? |
||||
|
AXUIElementCopyAttributeNames(elem, &attrNamesObj) |
||||
|
if let names = attrNamesObj as? [String] { |
||||
|
print("Attribute Names:", names) |
||||
|
for name in names { |
||||
|
var val: CFTypeRef? |
||||
|
let err = AXUIElementCopyAttributeValue(elem, name as CFString, &val) |
||||
|
if err == .success, let val = val { |
||||
|
print(" \(name): \(val)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if let focused = getFocusedElement() { |
||||
|
print("Found Focused Element:") |
||||
|
inspectElement(focused) |
||||
|
} else { |
||||
|
print("No focused element found") |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func findFocusedDescendant(_ elem: AXUIElement) -> AXUIElement? { |
||||
|
// Check if this element is a text field/text area or has value |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
let role = (roleObj as? String) ?? "" |
||||
|
|
||||
|
if role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXSearchField" { |
||||
|
return elem |
||||
|
} |
||||
|
|
||||
|
var focusedObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &focusedObj) == .success, |
||||
|
let isFocused = focusedObj as? Bool, isFocused { |
||||
|
// If it has value attribute, return it |
||||
|
var valObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success { |
||||
|
return elem |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check children |
||||
|
var childrenObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success, |
||||
|
let children = childrenObj as? [AXUIElement] { |
||||
|
for child in children { |
||||
|
if let found = findFocusedDescendant(child) { |
||||
|
return found |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func getDeepFocusedElement() -> (AXUIElement, String, String)? { |
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil } |
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
|
||||
|
// First try standard focused element |
||||
|
var focusedObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, |
||||
|
let elem = focusedObj as! AXUIElement? { |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
let role = (roleObj as? String) ?? "" |
||||
|
|
||||
|
var valObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) |
||||
|
let val = (valObj as? String) ?? "" |
||||
|
|
||||
|
if !val.isEmpty || role == "AXTextField" || role == "AXTextArea" { |
||||
|
return (elem, role, val) |
||||
|
} |
||||
|
|
||||
|
// If it's a window or web area, search descendants |
||||
|
if let deep = findFocusedDescendant(elem) { |
||||
|
var deepRoleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(deep, kAXRoleAttribute as CFString, &deepRoleObj) |
||||
|
let deepRole = (deepRoleObj as? String) ?? "" |
||||
|
|
||||
|
var deepValObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(deep, kAXValueAttribute as CFString, &deepValObj) |
||||
|
let deepVal = (deepValObj as? String) ?? "" |
||||
|
return (deep, deepRole, deepVal) |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
if let (elem, role, val) = getDeepFocusedElement() { |
||||
|
print("Found deep focused element! Role: \(role), Value: '\(val)'") |
||||
|
} else { |
||||
|
print("Deep focused element not found") |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func testLiveAX() { |
||||
|
let sysWide = AXUIElementCreateSystemWide() |
||||
|
|
||||
|
// Enable Chromium/Electron accessibility |
||||
|
AXUIElementSetAttributeValue(sysWide, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) |
||||
|
AXUIElementSetAttributeValue(sysWide, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
||||
|
|
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { |
||||
|
print("No front app") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("Front App:", frontApp.localizedName ?? "", "PID:", frontApp.processIdentifier) |
||||
|
|
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) |
||||
|
AXUIElementSetAttributeValue(appElem, "AXManualAccessibility" as CFString, kCFBooleanTrue) |
||||
|
|
||||
|
// Try system wide focused element |
||||
|
var focusedUIElement: CFTypeRef? |
||||
|
var err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedUIElement) |
||||
|
if err != .success || focusedUIElement == nil { |
||||
|
err = AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedUIElement) |
||||
|
} |
||||
|
|
||||
|
guard err == .success, let elem = focusedUIElement else { |
||||
|
print("No focused element. Error:", err.rawValue) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let axElem = elem as! AXUIElement |
||||
|
|
||||
|
var roleRef: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleRef) |
||||
|
let role = (roleRef as? String) ?? "AXUnknown" |
||||
|
print("Role:", role) |
||||
|
|
||||
|
// Extract text |
||||
|
var currentText = "" |
||||
|
var valueRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valueRef) == .success, |
||||
|
let val = valueRef { |
||||
|
if let str = val as? String { |
||||
|
currentText = str |
||||
|
} else if let attrStr = val as? NSAttributedString { |
||||
|
currentText = attrStr.string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if currentText.isEmpty { |
||||
|
var countRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(axElem, kAXNumberOfCharactersAttribute as CFString, &countRef) == .success, |
||||
|
let count = countRef as? Int { |
||||
|
var range = CFRange(location: 0, length: count) |
||||
|
if let axRange = AXValueCreate(.cfRange, &range) { |
||||
|
var stringRef: CFTypeRef? |
||||
|
if AXUIElementCopyParameterizedAttributeValue(axElem, kAXStringForRangeParameterizedAttribute as CFString, axRange, &stringRef) == .success, |
||||
|
let str = stringRef as? String { |
||||
|
currentText = str |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
print("Extracted Text: '\(currentText)'") |
||||
|
|
||||
|
// Selection / Cursor |
||||
|
var rangeRef: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(axElem, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success, |
||||
|
let val = rangeRef { |
||||
|
var cfRange = CFRange() |
||||
|
if AXValueGetValue(val as! AXValue, .cfRange, &cfRange) { |
||||
|
print("Cursor Location: \(cfRange.location), Selection Length: \(cfRange.length)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
testLiveAX() |
||||
@ -0,0 +1,21 @@ |
|||||
|
import Foundation |
||||
|
import Cocoa |
||||
|
|
||||
|
class TestTimer { |
||||
|
private var timerSource: DispatchSourceTimer? |
||||
|
|
||||
|
func start() { |
||||
|
let queue = DispatchQueue(label: "com.test.timer", qos: .userInteractive) |
||||
|
let timer = DispatchSource.makeTimerSource(queue: queue) |
||||
|
timer.schedule(deadline: .now(), repeating: .milliseconds(50)) |
||||
|
timer.setEventHandler { |
||||
|
if let (elem, app) = FocusedInputSync.shared.getFocusedElement() { |
||||
|
if let state = FocusedInputSync.shared.inspectCurrentState() { |
||||
|
print("Live State detected: '\(state.text)' in \(state.app)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
timer.resume() |
||||
|
self.timerSource = timer |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func printAXTree(_ elem: AXUIElement, depth: Int = 0) { |
||||
|
if depth > 7 { return } |
||||
|
let indent = String(repeating: " ", count: depth) |
||||
|
|
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
let role = (roleObj as? String) ?? "unknown" |
||||
|
|
||||
|
var valObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) |
||||
|
let val = (valObj as? String) ?? "" |
||||
|
|
||||
|
var titleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXTitleAttribute as CFString, &titleObj) |
||||
|
let title = (titleObj as? String) ?? "" |
||||
|
|
||||
|
var focusedObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(elem, kAXFocusedAttribute as CFString, &focusedObj) |
||||
|
let isFocused = (focusedObj as? Bool) ?? false |
||||
|
|
||||
|
print("\(indent)[\(role)] title='\(title)' val='\(val)' focused=\(isFocused)") |
||||
|
|
||||
|
var childrenObj: CFTypeRef? |
||||
|
if AXUIElementCopyAttributeValue(elem, kAXChildrenAttribute as CFString, &childrenObj) == .success, |
||||
|
let children = childrenObj as? [AXUIElement] { |
||||
|
for child in children { |
||||
|
printAXTree(child, depth: depth + 1) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if let frontApp = NSWorkspace.shared.frontmostApplication { |
||||
|
print("Front App:", frontApp.localizedName ?? "") |
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
printAXTree(appElem) |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
import Cocoa |
||||
|
import ApplicationServices |
||||
|
|
||||
|
func inspectFrontApp() { |
||||
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } |
||||
|
print("Frontmost App:", frontApp.localizedName ?? "") |
||||
|
|
||||
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) |
||||
|
|
||||
|
// 1. Try focused window |
||||
|
var windowObj: CFTypeRef? |
||||
|
var err = AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) |
||||
|
print("kAXFocusedWindowAttribute err:", err.rawValue) |
||||
|
|
||||
|
if err == .success, let win = windowObj { |
||||
|
let winElem = win as! AXUIElement |
||||
|
var focusedObj: CFTypeRef? |
||||
|
let winErr = AXUIElementCopyAttributeValue(winElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) |
||||
|
print("Window focused element err:", winErr.rawValue) |
||||
|
if winErr == .success, let elem = focusedObj { |
||||
|
let axElem = elem as! AXUIElement |
||||
|
var roleObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(axElem, kAXRoleAttribute as CFString, &roleObj) |
||||
|
print("Role from window:", roleObj ?? "none") |
||||
|
|
||||
|
var valObj: CFTypeRef? |
||||
|
AXUIElementCopyAttributeValue(axElem, kAXValueAttribute as CFString, &valObj) |
||||
|
print("Value from window:", valObj ?? "none") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
inspectFrontApp() |
||||