You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
209 lines
8.3 KiB
209 lines
8.3 KiB
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
|
|
|
|
// 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()
|
|
|
|
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() {
|
|
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()
|
|
}
|
|
}
|