diff --git a/Package.swift b/Package.swift index 688b0a6..e03f1f5 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,15 @@ let package = Package( .linkedFramework("Security"), ] ), + .executableTarget( + name: "WhisperKeyInput", + path: "Sources/WhisperKeyInput", + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("Carbon"), + .linkedFramework("InputMethodKit"), + ] + ), .testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"]) ] ) diff --git a/README.md b/README.md index 2bfddea..1957ec1 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ - `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo; - `Option+F5` — OpenAI Speech-to-Text API; - модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5; -- во время длинной диктовки готовые фразы вставляются после паузы около секунды, но не позднее 4 секунд непрерывной речи; -- технические четырёхсекундные фрагменты склеиваются без искусственной точки и заглавной буквы; +- с системным источником ввода черновик появляется через 2–3 секунды и отображается как нативный подчёркнутый `marked text`; +- после остановки вся реплика распознаётся целиком и заменяет подчёркнутый черновик финальным текстом; +- если системный источник не включён, работает прежняя безопасная вставка фрагментами; - короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать; - после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную; - результат очищается и вставляется в активное текстовое поле; @@ -25,6 +26,12 @@ chmod +x scripts/*.sh 1. доступ к микрофону; 2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ». +Установщик регистрирует системный источник `WhisperKey`. При F5 он выбирается +только на время диктовки, а после фиксации текста автоматически возвращается +предыдущая раскладка. Если macOS не разрешила автоматическое включение, откройте +`Системные настройки → Клавиатура → Ввод текста → Изменить…` и один раз +добавьте `WhisperKey` вручную. + Отключите штатное действие F5: `Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`. diff --git a/Resources/InputMethod-Info.plist b/Resources/InputMethod-Info.plist new file mode 100644 index 0000000..fa1aa46 --- /dev/null +++ b/Resources/InputMethod-Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + ru + CFBundleDisplayName + WhisperKey + CFBundleExecutable + WhisperKeyInput + CFBundleIdentifier + com.romanv.inputmethod.WhisperKey + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + WhisperKey + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 2 + InputMethodConnectionName + com.romanv.inputmethod.WhisperKey_Connection + InputMethodServerControllerClass + WhisperKeyInputController + LSBackgroundOnly + + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSPrincipalClass + NSApplication + TISIntendedLanguage + ru + tsInputMethodCharacterRepertoireKey + + Cyrl + Latn + + tsInputMethodIconFileKey + WhisperKeyInput.tiff + + diff --git a/Resources/WhisperKeyInput.pbm b/Resources/WhisperKeyInput.pbm new file mode 100644 index 0000000..48ca3f8 --- /dev/null +++ b/Resources/WhisperKeyInput.pbm @@ -0,0 +1,18 @@ +P1 +16 16 +0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 +0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 +0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 +0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 +0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 +0 0 1 0 0 1 0 0 0 0 1 0 0 1 0 0 +0 0 1 0 0 1 0 0 0 0 1 0 0 1 0 0 +0 0 1 0 0 0 1 0 0 1 0 0 0 1 0 0 +0 0 1 0 0 0 0 1 1 0 0 0 0 1 0 0 +0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 +0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 +0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 +0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 +0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/Sources/WhisperKey/AppDelegate.swift b/Sources/WhisperKey/AppDelegate.swift index 329459f..cdfb9d8 100644 --- a/Sources/WhisperKey/AppDelegate.swift +++ b/Sources/WhisperKey/AppDelegate.swift @@ -7,19 +7,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private struct PendingChunk { let url: URL let continuesPhrase: Bool + let isFinalSession: Bool } private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let recorder = AudioRecorder() private let hotKeys = HotKeyManager() + private let composition = NativeCompositionBridge() private let settings = AppSettings.shared private var activeMode: TranscriptionMode? + private var startingSession = false private var pendingChunks: [PendingChunk] = [] private var processingChunk = false private var stopRequested = false private var stitchNextChunk = false + private var nativeSession = false + private var nativeDraft = "" func applicationDidFinishLaunching(_ notification: Notification) { + composition.onAvailabilityChange = { [weak self] in + self?.rebuildMenu() + } + composition.start() configureStatusItem() requestPermissions() hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } } @@ -64,6 +73,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(.separator()) menu.addItem(item("Настройки…", #selector(openSettings))) menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine))) + let nativeState: String + if composition.isAvailable { + nativeState = "Нативный ввод — активен" + } else if composition.isInstalled { + nativeState = "Нативный ввод — готов" + } else { + nativeState = "Установить нативный ввод…" + } + menu.addItem(item(nativeState, #selector(openInputSources))) menu.addItem(.separator()) menu.addItem(item("Выход", #selector(quit), key: "q")) statusItem.menu = menu @@ -91,6 +109,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await stopAndTranscribe() return } + guard !startingSession else { return } + startingSession = true + defer { startingSession = false } + var nativeSelected = composition.activateForDictation() + if !nativeSelected { + try? await Task.sleep(for: .milliseconds(250)) + nativeSelected = composition.activateForDictation() + } + try? await Task.sleep(for: .milliseconds(180)) do { activeMode = mode lastMode = mode @@ -98,7 +125,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { processingChunk = false stopRequested = false stitchNextChunk = false + nativeDraft = "" + nativeSession = nativeSelected && composition.isAvailable + if nativeSession { + composition.cancel(restoreInputSource: false) + } else { + composition.restoreInputSource() + } try recorder.start( + chunkTiming: nativeSession ? .nativeDraft : .committed, onChunk: { [weak self] chunk in Task { @MainActor in self?.enqueue(chunk) } }, @@ -125,20 +160,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate { DictationSounds.playStop() setStatus("обрабатываю…") do { - if let final = try recorder.stop() { - enqueue(RecordedChunk(url: final, continuesPhrase: false)) + let result = try recorder.stop() + if nativeSession, let fullSession = result.fullSessionURL { + if let finalChunk = result.finalChunkURL { + try? FileManager.default.removeItem(at: finalChunk) + } + enqueue( + RecordedChunk(url: fullSession, continuesPhrase: false), + isFinalSession: true + ) + } else { + if let fullSession = result.fullSessionURL { + try? FileManager.default.removeItem(at: fullSession) + } + if let finalChunk = result.finalChunkURL { + enqueue(RecordedChunk(url: finalChunk, continuesPhrase: false)) + } + } + if pendingChunks.isEmpty, !processingChunk { + if nativeSession { composition.cancel() } + completeLiveSession() } - if pendingChunks.isEmpty, !processingChunk { completeLiveSession() } } catch { + composition.cancel() setStatus("ошибка") showError("Диктовка не удалась", error.localizedDescription) } } - private func enqueue(_ chunk: RecordedChunk) { + private func enqueue(_ chunk: RecordedChunk, isFinalSession: Bool = false) { pendingChunks.append(PendingChunk( url: chunk.url, - continuesPhrase: chunk.continuesPhrase + continuesPhrase: chunk.continuesPhrase, + isFinalSession: isFinalSession )) processNextChunk() } @@ -157,7 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { do { let wav = try AudioConverter.toWhisperWAV(recording) - saveDiagnosticRecording(wav) + if chunk.isFinalSession { saveDiagnosticRecording(wav) } defer { try? FileManager.default.removeItem(at: recording) try? FileManager.default.removeItem(at: wav) @@ -168,18 +222,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings) } let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary) - if !cleaned.isEmpty { + if chunk.isFinalSession { + if cleaned.isEmpty { + composition.cancel() + } else if !composition.commit(cleaned + " ") { + try TextInserter.insert(cleaned + " ") + } + nativeDraft = "" + stitchNextChunk = false + } else if !cleaned.isEmpty { let joined = TranscriptJoiner.prepare( cleaned, continuesPrevious: stitchNextChunk, continuesNext: chunk.continuesPhrase, glossary: settings.glossary ) - if !joined.isEmpty { try TextInserter.insert(joined + " ") } + if !joined.isEmpty { + if nativeSession { + nativeDraft += (nativeDraft.isEmpty ? "" : " ") + joined + _ = composition.updateDraft(nativeDraft) + } else { + try TextInserter.insert(joined + " ") + } + } stitchNextChunk = chunk.continuesPhrase } } catch { - showError("Фрагмент диктовки не распознан", error.localizedDescription) + if chunk.isFinalSession, !nativeDraft.isEmpty { + if !composition.commit(nativeDraft + " ") { + try? TextInserter.insert(nativeDraft + " ") + } + } + showError( + chunk.isFinalSession + ? "Финальная диктовка не распознана" + : "Фрагмент диктовки не распознан", + error.localizedDescription + ) } processingChunk = false if pendingChunks.isEmpty { @@ -208,12 +287,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { stopRequested = false lastMode = nil stitchNextChunk = false + nativeSession = false + nativeDraft = "" setStatus("готово") } @objc private func startLocal() { Task { await toggle(.local) } } @objc private func startCloud() { Task { await toggle(.cloud) } } @objc private func openSettings() { SettingsWindowController.shared.show() } + @objc private func openInputSources() { + let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")! + NSWorkspace.shared.open(url) + } @objc private func checkLocalEngine() { do { try LocalWhisper.validate(settings: settings) @@ -222,7 +307,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showError("Локальный движок не готов", error.localizedDescription) } } - @objc private func quit() { NSApp.terminate(nil) } + @objc private func quit() { + composition.cancel() + NSApp.terminate(nil) + } @objc private func handleWake() { do { diff --git a/Sources/WhisperKey/Audio.swift b/Sources/WhisperKey/Audio.swift index ea1f136..a205873 100644 --- a/Sources/WhisperKey/Audio.swift +++ b/Sources/WhisperKey/Audio.swift @@ -6,12 +6,37 @@ struct RecordedChunk: Sendable { let continuesPhrase: Bool } +struct RecordingResult: Sendable { + let finalChunkURL: URL? + let fullSessionURL: URL? +} + +struct ChunkTiming: Sendable { + let minimumDuration: TimeInterval + let pauseDuration: TimeInterval + let maximumDuration: TimeInterval + + static let committed = ChunkTiming( + minimumDuration: 2.5, + pauseDuration: 1.0, + maximumDuration: 4.0 + ) + static let nativeDraft = ChunkTiming( + minimumDuration: 1.5, + pauseDuration: 0.65, + maximumDuration: 2.25 + ) +} + final class AudioRecorder: @unchecked Sendable { private var engine: AVAudioEngine? private let lock = NSLock() private var noSpeechTimeout: DispatchWorkItem? private var currentURL: URL? private var outputFile: AVAudioFile? + private var sessionURL: URL? + private var sessionFile: AVAudioFile? + private var sessionFrames: AVAudioFramePosition = 0 private var currentFrames: AVAudioFramePosition = 0 private var currentChunkHasSpeech = false private var heardSpeech = false @@ -21,8 +46,10 @@ final class AudioRecorder: @unchecked Sendable { private var onChunk: (@Sendable (RecordedChunk) -> Void)? private var onSilence: (@Sendable () -> Void)? private var recordingFormat: AVAudioFormat? + private var chunkTiming = ChunkTiming.committed func start( + chunkTiming: ChunkTiming, onChunk: @escaping @Sendable (RecordedChunk) -> Void, onSilence: @escaping @Sendable () -> Void ) throws { @@ -33,8 +60,17 @@ final class AudioRecorder: @unchecked Sendable { let format = input.outputFormat(forBus: 0) self.onChunk = onChunk self.onSilence = onSilence + self.chunkTiming = chunkTiming recordingFormat = format - try beginChunk(format: format) + do { + try beginSession(format: format) + try beginChunk(format: format) + } catch { + sessionFile = nil + if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) } + self.sessionURL = nil + throw error + } input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in self?.consume(buffer, format: format) } @@ -47,13 +83,16 @@ final class AudioRecorder: @unchecked Sendable { engine.reset() self.engine = nil if let currentURL { try? FileManager.default.removeItem(at: currentURL) } + if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) } outputFile = nil currentURL = nil + sessionFile = nil + sessionURL = nil throw error } } - func stop() throws -> URL? { + func stop() throws -> RecordingResult { noSpeechTimeout?.cancel() noSpeechTimeout = nil if let engine { @@ -66,19 +105,29 @@ final class AudioRecorder: @unchecked Sendable { } lock.lock() defer { lock.unlock() } - let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil - if finalURL == nil, let currentURL { + let finalChunkURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil + let fullSessionURL = sessionFrames > 0 && heardSpeech ? sessionURL : nil + if finalChunkURL == nil, let currentURL { try? FileManager.default.removeItem(at: currentURL) } + if fullSessionURL == nil, let sessionURL { + try? FileManager.default.removeItem(at: sessionURL) + } outputFile = nil currentURL = nil + sessionFile = nil + sessionURL = nil + sessionFrames = 0 currentFrames = 0 currentChunkHasSpeech = false heardSpeech = false silentFrames = 0 chunkSilentFrames = 0 silenceTriggered = false - return finalURL + return RecordingResult( + finalChunkURL: finalChunkURL, + fullSessionURL: fullSessionURL + ) } private func scheduleNoSpeechTimeout() { @@ -103,12 +152,22 @@ final class AudioRecorder: @unchecked Sendable { chunkSilentFrames = 0 } + private func beginSession(format: AVAudioFormat) throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("whisperkey-session-\(UUID().uuidString).caf") + sessionFile = try AVAudioFile(forWriting: url, settings: format.settings) + sessionURL = url + sessionFrames = 0 + } + private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { lock.lock() defer { lock.unlock() } guard let activeOutputFile = outputFile else { return } try? activeOutputFile.write(from: buffer) + try? sessionFile?.write(from: buffer) currentFrames += AVAudioFramePosition(buffer.frameLength) + sessionFrames += AVAudioFramePosition(buffer.frameLength) let rms = bufferRMS(buffer) if rms > 0.006 { @@ -133,9 +192,9 @@ final class AudioRecorder: @unchecked Sendable { // Keep enough context for Whisper, but insert completed phrases while // the user is still dictating. Prefer a short natural pause; cap a // continuous phrase so the input never stays blank indefinitely. - let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5) - let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0) - let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0) + let minimumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.minimumDuration) + let pauseBoundary = AVAudioFramePosition(format.sampleRate * chunkTiming.pauseDuration) + let maximumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.maximumDuration) let reachedPause = currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary let reachedMaximum = currentFrames >= maximumChunk diff --git a/Sources/WhisperKey/NativeCompositionBridge.swift b/Sources/WhisperKey/NativeCompositionBridge.swift new file mode 100644 index 0000000..e78d110 --- /dev/null +++ b/Sources/WhisperKey/NativeCompositionBridge.swift @@ -0,0 +1,171 @@ +import AppKit +import Carbon +import Foundation + +@MainActor +final class NativeCompositionBridge: NSObject { + private enum Message { + static let ping = Notification.Name("com.romanv.WhisperKeyInput.ping") + static let ready = Notification.Name("com.romanv.WhisperKeyInput.ready") + static let draft = Notification.Name("com.romanv.WhisperKeyInput.draft") + static let commit = Notification.Name("com.romanv.WhisperKeyInput.commit") + static let cancel = Notification.Name("com.romanv.WhisperKeyInput.cancel") + static let textKey = "text" + static let activeKey = "active" + } + + private static let inputSourceID = "com.romanv.inputmethod.WhisperKey" + private static let installedBundle = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Input Methods/WhisperKeyInput.app", isDirectory: true) + + var onAvailabilityChange: (() -> Void)? + private var previousInputSourceID: String? + private var restoreWorkItem: DispatchWorkItem? + private(set) var inputMethodActive = false { + didSet { + if oldValue != inputMethodActive { onAvailabilityChange?() } + } + } + + var isAvailable: Bool { + inputMethodActive && currentInputSourceID() == Self.inputSourceID + } + + var isInstalled: Bool { + FileManager.default.fileExists(atPath: Self.installedBundle.path) + } + + func start() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleReady), + name: Message.ready, + object: nil + ) + registerInstalledInputMethod() + launchInputMethod() + ping() + } + + func activateForDictation() -> Bool { + restoreWorkItem?.cancel() + restoreWorkItem = nil + registerInstalledInputMethod() + launchInputMethod() + + guard let source = inputSource(withID: Self.inputSourceID) else { return false } + let currentID = currentInputSourceID() + if currentID != Self.inputSourceID { + previousInputSourceID = currentID + } + guard TISEnableInputSource(source) == noErr, + TISSelectInputSource(source) == noErr else { + restoreInputSource() + return false + } + ping() + return true + } + + func ping() { + DistributedNotificationCenter.default().post( + name: Message.ping, + object: nil, + userInfo: nil + ) + } + + @discardableResult + func updateDraft(_ text: String) -> Bool { + guard isAvailable else { return false } + post(name: Message.draft, text: text) + return true + } + + @discardableResult + func commit(_ text: String) -> Bool { + let available = isAvailable + if available { post(name: Message.commit, text: text) } + scheduleInputSourceRestore() + return available + } + + func cancel(restoreInputSource: Bool = true) { + if inputMethodActive { + DistributedNotificationCenter.default().post( + name: Message.cancel, + object: nil, + userInfo: nil + ) + } + if restoreInputSource { scheduleInputSourceRestore() } + } + + func restoreInputSource() { + restoreWorkItem?.cancel() + restoreWorkItem = nil + guard let previousInputSourceID else { return } + self.previousInputSourceID = nil + guard let source = inputSource(withID: previousInputSourceID) else { return } + _ = TISSelectInputSource(source) + } + + private func post(name: Notification.Name, text: String) { + DistributedNotificationCenter.default().post( + name: name, + object: nil, + userInfo: [Message.textKey: text] + ) + } + + @objc private func handleReady(_ notification: Notification) { + inputMethodActive = notification.userInfo?[Message.activeKey] as? Bool ?? false + } + + private func registerInstalledInputMethod() { + guard isInstalled else { return } + _ = TISRegisterInputSource(Self.installedBundle as CFURL) + } + + private func launchInputMethod() { + guard isInstalled else { return } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + NSWorkspace.shared.openApplication( + at: Self.installedBundle, + configuration: configuration + ) + } + + private func scheduleInputSourceRestore() { + restoreWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.restoreInputSource() + } + restoreWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + private func inputSource(withID identifier: String) -> TISInputSource? { + guard let sources = TISCreateInputSourceList(nil, true)?.takeRetainedValue() + as? [TISInputSource] else { + return nil + } + return sources.first { inputSourceID(for: $0) == identifier } + } + + private func currentInputSourceID() -> String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return nil } + return inputSourceID(for: source) + } + + private func inputSourceID(for source: TISInputSource) -> String? { + guard let rawValue = TISGetInputSourceProperty( + source, + kTISPropertyInputSourceID + ) else { return nil } + return Unmanaged + .fromOpaque(rawValue) + .takeUnretainedValue() as String + } +} diff --git a/Sources/WhisperKey/SettingsWindow.swift b/Sources/WhisperKey/SettingsWindow.swift index 4bcf283..b907fac 100644 --- a/Sources/WhisperKey/SettingsWindow.swift +++ b/Sources/WhisperKey/SettingsWindow.swift @@ -44,7 +44,7 @@ final class SettingsWindowController: NSWindowController { grid.column(at: 0).xPlacement = .trailing modelPopup.addItems(withTitles: ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]) - let note = NSTextField(wrappingLabelWithString: "F5 — локально. Option+F5 — OpenAI. Вся реплика распознаётся целиком после 5 секунд тишины. Повторное нажатие останавливает её вручную.") + let note = NSTextField(wrappingLabelWithString: "F5 — локально. Option+F5 — OpenAI. С нативным источником ввода во время речи показывается подчёркнутый черновик, а после 5 секунд тишины он заменяется финальным текстом.") note.textColor = .secondaryLabelColor note.translatesAutoresizingMaskIntoConstraints = false let save = NSButton(title: "Сохранить", target: self, action: #selector(save)) diff --git a/Sources/WhisperKeyInput/main.swift b/Sources/WhisperKeyInput/main.swift new file mode 100644 index 0000000..07c212b --- /dev/null +++ b/Sources/WhisperKeyInput/main.swift @@ -0,0 +1,172 @@ +import AppKit +import Carbon +import InputMethodKit +import OSLog + +private let inputLog = Logger( + subsystem: "com.romanv.inputmethod.WhisperKey", + category: "composition" +) + +private enum CompositionMessage { + static let ping = Notification.Name("com.romanv.WhisperKeyInput.ping") + static let ready = Notification.Name("com.romanv.WhisperKeyInput.ready") + static let draft = Notification.Name("com.romanv.WhisperKeyInput.draft") + static let commit = Notification.Name("com.romanv.WhisperKeyInput.commit") + static let cancel = Notification.Name("com.romanv.WhisperKeyInput.cancel") + static let textKey = "text" + static let activeKey = "active" +} + +private final class CompositionCoordinator: @unchecked Sendable { + static let shared = CompositionCoordinator() + + weak var activeController: WhisperKeyInputController? { + didSet { announceReady() } + } + + private init() { + let center = DistributedNotificationCenter.default() + center.addObserver( + self, + selector: #selector(handlePing), + name: CompositionMessage.ping, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleDraft), + name: CompositionMessage.draft, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleCommit), + name: CompositionMessage.commit, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleCancel), + name: CompositionMessage.cancel, + object: nil + ) + } + + @objc private func handlePing() { + NSLog("WhisperKeyInput: ping") + announceReady() + } + + @objc private func handleDraft(_ notification: Notification) { + guard let text = notification.userInfo?[CompositionMessage.textKey] as? String else { return } + NSLog("WhisperKeyInput: draft %ld", (text as NSString).length) + activeController?.setDraft(text) + } + + @objc private func handleCommit(_ notification: Notification) { + guard let text = notification.userInfo?[CompositionMessage.textKey] as? String else { return } + NSLog("WhisperKeyInput: commit %ld", (text as NSString).length) + activeController?.commit(text) + } + + @objc private func handleCancel() { + NSLog("WhisperKeyInput: cancel") + activeController?.cancelDraft() + } + + private func announceReady() { + DistributedNotificationCenter.default().post( + name: CompositionMessage.ready, + object: nil, + userInfo: [CompositionMessage.activeKey: activeController != nil] + ) + } +} + +@objc(WhisperKeyInputController) +final class WhisperKeyInputController: IMKInputController { + private var draft = "" + + override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { + super.init(server: server, delegate: delegate, client: inputClient) + _ = CompositionCoordinator.shared + NSLog("WhisperKeyInput: controller initialized") + } + + override func activateServer(_ sender: Any!) { + super.activateServer(sender) + inputLog.notice( + "controller activated for \(self.client().bundleIdentifier(), privacy: .public)" + ) + CompositionCoordinator.shared.activeController = self + client().overrideKeyboard(withKeyboardNamed: "com.apple.keylayout.Russian") + } + + override func deactivateServer(_ sender: Any!) { + cancelDraft() + if CompositionCoordinator.shared.activeController === self { + CompositionCoordinator.shared.activeController = nil + } + super.deactivateServer(sender) + } + + override func inputControllerWillClose() { + if CompositionCoordinator.shared.activeController === self { + CompositionCoordinator.shared.activeController = nil + } + super.inputControllerWillClose() + } + + func setDraft(_ text: String) { + inputLog.notice( + "setting marked text for \(self.client().bundleIdentifier(), privacy: .public)" + ) + draft = text + let length = (text as NSString).length + client().setMarkedText( + text, + selectionRange: NSRange(location: length, length: 0), + replacementRange: activeReplacementRange() + ) + } + + func commit(_ text: String) { + inputLog.notice( + "inserting final text for \(self.client().bundleIdentifier(), privacy: .public)" + ) + client().insertText(text, replacementRange: activeReplacementRange()) + draft = "" + } + + func cancelDraft() { + guard !draft.isEmpty else { return } + client().insertText("", replacementRange: activeReplacementRange()) + draft = "" + } + + private func activeReplacementRange() -> NSRange { + let marked = client().markedRange() + return marked.location == NSNotFound + ? NSRange(location: NSNotFound, length: 0) + : marked + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.prohibited) + +let connectionName = + Bundle.main.object(forInfoDictionaryKey: "InputMethodConnectionName") as? String + ?? "com.romanv.inputmethod.WhisperKey_Connection" +let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.romanv.inputmethod.WhisperKey" +guard let inputServer = IMKServer( + name: connectionName, + bundleIdentifier: bundleIdentifier +) else { + fatalError("Не удалось запустить WhisperKey Input Method.") +} +_ = CompositionCoordinator.shared +NSLog("WhisperKeyInput: server started") +app.run() +withExtendedLifetime(inputServer) {} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 314afde..96b11cf 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -3,6 +3,7 @@ set -euo pipefail PROJECT_DIR=${0:A:h:h} APP_DIR="$PROJECT_DIR/dist/WhisperKey.app" +INPUT_APP_DIR="$PROJECT_DIR/dist/WhisperKeyInput.app" cd "$PROJECT_DIR" swift build -c release @@ -13,4 +14,13 @@ cp "$PROJECT_DIR/.build/release/WhisperKey" "$APP_DIR/Contents/MacOS/WhisperKey" cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist" codesign --force --deep --sign - "$APP_DIR" +rm -rf "$INPUT_APP_DIR" +mkdir -p "$INPUT_APP_DIR/Contents/MacOS" "$INPUT_APP_DIR/Contents/Resources" +cp "$PROJECT_DIR/.build/release/WhisperKeyInput" "$INPUT_APP_DIR/Contents/MacOS/WhisperKeyInput" +cp "$PROJECT_DIR/Resources/InputMethod-Info.plist" "$INPUT_APP_DIR/Contents/Info.plist" +sips -s format tiff "$PROJECT_DIR/Resources/WhisperKeyInput.pbm" \ + --out "$INPUT_APP_DIR/Contents/Resources/WhisperKeyInput.tiff" >/dev/null +codesign --force --deep --sign - "$INPUT_APP_DIR" + echo "$APP_DIR" +echo "$INPUT_APP_DIR" diff --git a/scripts/install-app.sh b/scripts/install-app.sh index f86b629..b110308 100755 --- a/scripts/install-app.sh +++ b/scripts/install-app.sh @@ -6,6 +6,12 @@ PROJECT_DIR=${0:A:h:h} ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app" +INPUT_METHOD_DIR="$HOME/Library/Input Methods" +mkdir -p "$INPUT_METHOD_DIR" +pkill -x WhisperKeyInput 2>/dev/null || true +ditto "$PROJECT_DIR/dist/WhisperKeyInput.app" "$INPUT_METHOD_DIR/WhisperKeyInput.app" +open -gja "$INPUT_METHOD_DIR/WhisperKeyInput.app" + LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents" LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist" mkdir -p "$LOGIN_AGENT_DIR" @@ -16,3 +22,4 @@ launchctl bootstrap "gui/$(id -u)" "$LOGIN_AGENT" open "/Applications/WhisperKey.app" echo "Installed: /Applications/WhisperKey.app" +echo "Installed: $INPUT_METHOD_DIR/WhisperKeyInput.app"