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