diff --git a/Package.swift b/Package.swift
index e03f1f5..688b0a6 100644
--- a/Package.swift
+++ b/Package.swift
@@ -15,15 +15,6 @@ 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 1957ec1..2bfddea 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,8 @@
- `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo;
- `Option+F5` — OpenAI Speech-to-Text API;
- модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5;
-- с системным источником ввода черновик появляется через 2–3 секунды и отображается как нативный подчёркнутый `marked text`;
-- после остановки вся реплика распознаётся целиком и заменяет подчёркнутый черновик финальным текстом;
-- если системный источник не включён, работает прежняя безопасная вставка фрагментами;
+- во время длинной диктовки готовые фразы вставляются после паузы около секунды, но не позднее 4 секунд непрерывной речи;
+- технические четырёхсекундные фрагменты склеиваются без искусственной точки и заглавной буквы;
- короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать;
- после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную;
- результат очищается и вставляется в активное текстовое поле;
@@ -26,12 +25,6 @@ chmod +x scripts/*.sh
1. доступ к микрофону;
2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ».
-Установщик регистрирует системный источник `WhisperKey`. При F5 он выбирается
-только на время диктовки, а после фиксации текста автоматически возвращается
-предыдущая раскладка. Если macOS не разрешила автоматическое включение, откройте
-`Системные настройки → Клавиатура → Ввод текста → Изменить…` и один раз
-добавьте `WhisperKey` вручную.
-
Отключите штатное действие F5:
`Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`.
diff --git a/Resources/InputMethod-Info.plist b/Resources/InputMethod-Info.plist
deleted file mode 100644
index b3ec46c..0000000
--- a/Resources/InputMethod-Info.plist
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
- CFBundleDevelopmentRegion
- ru
- CFBundleDisplayName
- WhisperKey
- CFBundleExecutable
- WhisperKeyInput
- CFBundleIdentifier
- com.romanv.inputmethod.WhisperKey
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- WhisperKey
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 0.2.1
- CFBundleVersion
- 3
- InputMethodConnectionName
- com.romanv.inputmethod.WhisperKey_Connection
- InputMethodServerControllerClass
- WhisperKeyInputController
- LSBackgroundOnly
-
- LSMinimumSystemVersion
- 14.0
- LSUIElement
-
- NSPrincipalClass
- NSApplication
- TISIntendedLanguage
- ru
- tsInputMethodCharacterRepertoireKey
-
- Cyrl
- Latn
-
- tsInputMethodIconFileKey
- WhisperKeyInputTemplateV2.tiff
-
-
diff --git a/Resources/WhisperKeyInput.svg b/Resources/WhisperKeyInput.svg
deleted file mode 100644
index faa4114..0000000
--- a/Resources/WhisperKeyInput.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
diff --git a/Sources/WhisperKey/AppDelegate.swift b/Sources/WhisperKey/AppDelegate.swift
index cdfb9d8..329459f 100644
--- a/Sources/WhisperKey/AppDelegate.swift
+++ b/Sources/WhisperKey/AppDelegate.swift
@@ -7,28 +7,19 @@ 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) } }
@@ -73,15 +64,6 @@ 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
@@ -109,15 +91,6 @@ 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
@@ -125,15 +98,7 @@ 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) }
},
@@ -160,39 +125,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DictationSounds.playStop()
setStatus("обрабатываю…")
do {
- 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 let final = try recorder.stop() {
+ enqueue(RecordedChunk(url: final, continuesPhrase: false))
}
+ if pendingChunks.isEmpty, !processingChunk { completeLiveSession() }
} catch {
- composition.cancel()
setStatus("ошибка")
showError("Диктовка не удалась", error.localizedDescription)
}
}
- private func enqueue(_ chunk: RecordedChunk, isFinalSession: Bool = false) {
+ private func enqueue(_ chunk: RecordedChunk) {
pendingChunks.append(PendingChunk(
url: chunk.url,
- continuesPhrase: chunk.continuesPhrase,
- isFinalSession: isFinalSession
+ continuesPhrase: chunk.continuesPhrase
))
processNextChunk()
}
@@ -211,7 +157,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task {
do {
let wav = try AudioConverter.toWhisperWAV(recording)
- if chunk.isFinalSession { saveDiagnosticRecording(wav) }
+ saveDiagnosticRecording(wav)
defer {
try? FileManager.default.removeItem(at: recording)
try? FileManager.default.removeItem(at: wav)
@@ -222,43 +168,18 @@ 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 chunk.isFinalSession {
- if cleaned.isEmpty {
- composition.cancel()
- } else if !composition.commit(cleaned + " ") {
- try TextInserter.insert(cleaned + " ")
- }
- nativeDraft = ""
- stitchNextChunk = false
- } else if !cleaned.isEmpty {
+ if !cleaned.isEmpty {
let joined = TranscriptJoiner.prepare(
cleaned,
continuesPrevious: stitchNextChunk,
continuesNext: chunk.continuesPhrase,
glossary: settings.glossary
)
- if !joined.isEmpty {
- if nativeSession {
- nativeDraft += (nativeDraft.isEmpty ? "" : " ") + joined
- _ = composition.updateDraft(nativeDraft)
- } else {
- try TextInserter.insert(joined + " ")
- }
- }
+ if !joined.isEmpty { try TextInserter.insert(joined + " ") }
stitchNextChunk = chunk.continuesPhrase
}
} catch {
- if chunk.isFinalSession, !nativeDraft.isEmpty {
- if !composition.commit(nativeDraft + " ") {
- try? TextInserter.insert(nativeDraft + " ")
- }
- }
- showError(
- chunk.isFinalSession
- ? "Финальная диктовка не распознана"
- : "Фрагмент диктовки не распознан",
- error.localizedDescription
- )
+ showError("Фрагмент диктовки не распознан", error.localizedDescription)
}
processingChunk = false
if pendingChunks.isEmpty {
@@ -287,18 +208,12 @@ 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)
@@ -307,10 +222,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
showError("Локальный движок не готов", error.localizedDescription)
}
}
- @objc private func quit() {
- composition.cancel()
- NSApp.terminate(nil)
- }
+ @objc private func quit() { NSApp.terminate(nil) }
@objc private func handleWake() {
do {
diff --git a/Sources/WhisperKey/Audio.swift b/Sources/WhisperKey/Audio.swift
index a205873..ea1f136 100644
--- a/Sources/WhisperKey/Audio.swift
+++ b/Sources/WhisperKey/Audio.swift
@@ -6,37 +6,12 @@ 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
@@ -46,10 +21,8 @@ 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 {
@@ -60,17 +33,8 @@ final class AudioRecorder: @unchecked Sendable {
let format = input.outputFormat(forBus: 0)
self.onChunk = onChunk
self.onSilence = onSilence
- self.chunkTiming = chunkTiming
recordingFormat = 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
- }
+ try beginChunk(format: format)
input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in
self?.consume(buffer, format: format)
}
@@ -83,16 +47,13 @@ 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 -> RecordingResult {
+ func stop() throws -> URL? {
noSpeechTimeout?.cancel()
noSpeechTimeout = nil
if let engine {
@@ -105,29 +66,19 @@ final class AudioRecorder: @unchecked Sendable {
}
lock.lock()
defer { lock.unlock() }
- let finalChunkURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
- let fullSessionURL = sessionFrames > 0 && heardSpeech ? sessionURL : nil
- if finalChunkURL == nil, let currentURL {
+ let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
+ if finalURL == 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 RecordingResult(
- finalChunkURL: finalChunkURL,
- fullSessionURL: fullSessionURL
- )
+ return finalURL
}
private func scheduleNoSpeechTimeout() {
@@ -152,22 +103,12 @@ 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 {
@@ -192,9 +133,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 * chunkTiming.minimumDuration)
- let pauseBoundary = AVAudioFramePosition(format.sampleRate * chunkTiming.pauseDuration)
- let maximumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.maximumDuration)
+ let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5)
+ let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0)
+ let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0)
let reachedPause =
currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary
let reachedMaximum = currentFrames >= maximumChunk
diff --git a/Sources/WhisperKey/NativeCompositionBridge.swift b/Sources/WhisperKey/NativeCompositionBridge.swift
deleted file mode 100644
index 6793ae9..0000000
--- a/Sources/WhisperKey/NativeCompositionBridge.swift
+++ /dev/null
@@ -1,195 +0,0 @@
-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
- }
-
- var isInstalled: Bool {
- FileManager.default.fileExists(atPath: Self.installedBundle.path)
- }
-
- func start() {
- DistributedNotificationCenter.default().addObserver(
- self,
- selector: #selector(handleReady),
- name: Message.ready,
- object: nil
- )
- if inputSource(withID: Self.inputSourceID) == nil {
- registerInstalledInputMethod()
- }
- launchInputMethod()
- ping()
- }
-
- func activateForDictation() -> Bool {
- restoreWorkItem?.cancel()
- restoreWorkItem = nil
- launchInputMethod()
-
- guard let source = inputSource(withID: Self.inputSourceID) else { return false }
- let currentID = currentInputSourceID()
- if currentID != Self.inputSourceID {
- previousInputSourceID = currentID
- }
- guard TISSelectInputSource(source) == noErr else {
- restoreInputSource()
- return false
- }
- wakeInputMethod()
- 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 wakeInputMethod() {
- guard let source = CGEventSource(stateID: .hidSystemState) else { return }
- let flags: CGEventFlags = [
- .maskCommand,
- .maskAlternate,
- .maskControl,
- .maskShift,
- ]
- guard let keyDown = CGEvent(
- keyboardEventSource: source,
- virtualKey: CGKeyCode(kVK_F20),
- keyDown: true
- ), let keyUp = CGEvent(
- keyboardEventSource: source,
- virtualKey: CGKeyCode(kVK_F20),
- keyDown: false
- ) else { return }
- keyDown.flags = flags
- keyUp.flags = flags
- keyDown.post(tap: .cghidEventTap)
- keyUp.post(tap: .cghidEventTap)
- }
-
- 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 b907fac..4bcf283 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
deleted file mode 100644
index a7b3464..0000000
--- a/Sources/WhisperKeyInput/main.swift
+++ /dev/null
@@ -1,188 +0,0 @@
-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 = ""
- private static let wakeModifiers: NSEvent.ModifierFlags = [
- .command,
- .option,
- .control,
- .shift,
- ]
-
- 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()
- }
-
- override func handle(_ event: NSEvent!, client sender: Any!) -> Bool {
- let isWakeEvent = event.type == .keyDown
- && event.keyCode == UInt16(kVK_F20)
- && event.modifierFlags.contains(Self.wakeModifiers)
- if isWakeEvent {
- inputLog.notice("wake event consumed")
- }
- return isWakeEvent
- }
-
- 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 13a9a2d..edce966 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -3,9 +3,9 @@ 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"
+rm -rf "$PROJECT_DIR/dist/WhisperKeyInput.app"
swift build -c release
rm -rf "$APP_DIR"
@@ -14,13 +14,4 @@ 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.svg" \
- --out "$INPUT_APP_DIR/Contents/Resources/WhisperKeyInputTemplateV2.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 2b72f3c..76c03d9 100755
--- a/scripts/install-app.sh
+++ b/scripts/install-app.sh
@@ -8,11 +8,9 @@ pkill -x WhisperKey 2>/dev/null || true
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
rm -rf "$INPUT_METHOD_DIR/WhisperKeyInput.app"
-ditto "$PROJECT_DIR/dist/WhisperKeyInput.app" "$INPUT_METHOD_DIR/WhisperKeyInput.app"
-open -gja "$INPUT_METHOD_DIR/WhisperKeyInput.app"
+killall TextInputMenuAgent 2>/dev/null || true
LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents"
LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist"
@@ -24,4 +22,3 @@ launchctl bootstrap "gui/$(id -u)" "$LOGIN_AGENT"
open "/Applications/WhisperKey.app"
echo "Installed: /Applications/WhisperKey.app"
-echo "Installed: $INPUT_METHOD_DIR/WhisperKeyInput.app"