Удалить нестабильный системный ввод
This commit is contained in:
@@ -15,15 +15,6 @@ let package = Package(
|
|||||||
.linkedFramework("Security"),
|
.linkedFramework("Security"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.executableTarget(
|
|
||||||
name: "WhisperKeyInput",
|
|
||||||
path: "Sources/WhisperKeyInput",
|
|
||||||
linkerSettings: [
|
|
||||||
.linkedFramework("AppKit"),
|
|
||||||
.linkedFramework("Carbon"),
|
|
||||||
.linkedFramework("InputMethodKit"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"])
|
.testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"])
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
- `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo;
|
- `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo;
|
||||||
- `Option+F5` — OpenAI Speech-to-Text API;
|
- `Option+F5` — OpenAI Speech-to-Text API;
|
||||||
- модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5;
|
- модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5;
|
||||||
- с системным источником ввода черновик появляется через 2–3 секунды и отображается как нативный подчёркнутый `marked text`;
|
- во время длинной диктовки готовые фразы вставляются после паузы около секунды, но не позднее 4 секунд непрерывной речи;
|
||||||
- после остановки вся реплика распознаётся целиком и заменяет подчёркнутый черновик финальным текстом;
|
- технические четырёхсекундные фрагменты склеиваются без искусственной точки и заглавной буквы;
|
||||||
- если системный источник не включён, работает прежняя безопасная вставка фрагментами;
|
|
||||||
- короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать;
|
- короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать;
|
||||||
- после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную;
|
- после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную;
|
||||||
- результат очищается и вставляется в активное текстовое поле;
|
- результат очищается и вставляется в активное текстовое поле;
|
||||||
@@ -26,12 +25,6 @@ chmod +x scripts/*.sh
|
|||||||
1. доступ к микрофону;
|
1. доступ к микрофону;
|
||||||
2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ».
|
2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ».
|
||||||
|
|
||||||
Установщик регистрирует системный источник `WhisperKey`. При F5 он выбирается
|
|
||||||
только на время диктовки, а после фиксации текста автоматически возвращается
|
|
||||||
предыдущая раскладка. Если macOS не разрешила автоматическое включение, откройте
|
|
||||||
`Системные настройки → Клавиатура → Ввод текста → Изменить…` и один раз
|
|
||||||
добавьте `WhisperKey` вручную.
|
|
||||||
|
|
||||||
Отключите штатное действие F5:
|
Отключите штатное действие F5:
|
||||||
|
|
||||||
`Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`.
|
`Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`.
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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>ru</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>WhisperKey</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>WhisperKeyInput</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.romanv.inputmethod.WhisperKey</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>WhisperKey</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>0.2.1</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>3</string>
|
|
||||||
<key>InputMethodConnectionName</key>
|
|
||||||
<string>com.romanv.inputmethod.WhisperKey_Connection</string>
|
|
||||||
<key>InputMethodServerControllerClass</key>
|
|
||||||
<string>WhisperKeyInputController</string>
|
|
||||||
<key>LSBackgroundOnly</key>
|
|
||||||
<true/>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
|
||||||
<string>14.0</string>
|
|
||||||
<key>LSUIElement</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSPrincipalClass</key>
|
|
||||||
<string>NSApplication</string>
|
|
||||||
<key>TISIntendedLanguage</key>
|
|
||||||
<string>ru</string>
|
|
||||||
<key>tsInputMethodCharacterRepertoireKey</key>
|
|
||||||
<array>
|
|
||||||
<string>Cyrl</string>
|
|
||||||
<string>Latn</string>
|
|
||||||
</array>
|
|
||||||
<key>tsInputMethodIconFileKey</key>
|
|
||||||
<string>WhisperKeyInputTemplateV2.tiff</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<g
|
|
||||||
fill="none"
|
|
||||||
stroke="#000"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="11" y="3.5" width="10" height="17" rx="5"/>
|
|
||||||
<path d="M7.5 15.5v.75a8.5 8.5 0 0 0 17 0v-.75"/>
|
|
||||||
<path d="M16 24.75v3.75"/>
|
|
||||||
<path d="M11.5 28.5h9"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 365 B |
@@ -7,28 +7,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private struct PendingChunk {
|
private struct PendingChunk {
|
||||||
let url: URL
|
let url: URL
|
||||||
let continuesPhrase: Bool
|
let continuesPhrase: Bool
|
||||||
let isFinalSession: Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
private let recorder = AudioRecorder()
|
private let recorder = AudioRecorder()
|
||||||
private let hotKeys = HotKeyManager()
|
private let hotKeys = HotKeyManager()
|
||||||
private let composition = NativeCompositionBridge()
|
|
||||||
private let settings = AppSettings.shared
|
private let settings = AppSettings.shared
|
||||||
private var activeMode: TranscriptionMode?
|
private var activeMode: TranscriptionMode?
|
||||||
private var startingSession = false
|
|
||||||
private var pendingChunks: [PendingChunk] = []
|
private var pendingChunks: [PendingChunk] = []
|
||||||
private var processingChunk = false
|
private var processingChunk = false
|
||||||
private var stopRequested = false
|
private var stopRequested = false
|
||||||
private var stitchNextChunk = false
|
private var stitchNextChunk = false
|
||||||
private var nativeSession = false
|
|
||||||
private var nativeDraft = ""
|
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
composition.onAvailabilityChange = { [weak self] in
|
|
||||||
self?.rebuildMenu()
|
|
||||||
}
|
|
||||||
composition.start()
|
|
||||||
configureStatusItem()
|
configureStatusItem()
|
||||||
requestPermissions()
|
requestPermissions()
|
||||||
hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } }
|
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(.separator())
|
||||||
menu.addItem(item("Настройки…", #selector(openSettings)))
|
menu.addItem(item("Настройки…", #selector(openSettings)))
|
||||||
menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine)))
|
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(.separator())
|
||||||
menu.addItem(item("Выход", #selector(quit), key: "q"))
|
menu.addItem(item("Выход", #selector(quit), key: "q"))
|
||||||
statusItem.menu = menu
|
statusItem.menu = menu
|
||||||
@@ -109,15 +91,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
await stopAndTranscribe()
|
await stopAndTranscribe()
|
||||||
return
|
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 {
|
do {
|
||||||
activeMode = mode
|
activeMode = mode
|
||||||
lastMode = mode
|
lastMode = mode
|
||||||
@@ -125,15 +98,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
processingChunk = false
|
processingChunk = false
|
||||||
stopRequested = false
|
stopRequested = false
|
||||||
stitchNextChunk = false
|
stitchNextChunk = false
|
||||||
nativeDraft = ""
|
|
||||||
nativeSession = nativeSelected && composition.isAvailable
|
|
||||||
if nativeSession {
|
|
||||||
composition.cancel(restoreInputSource: false)
|
|
||||||
} else {
|
|
||||||
composition.restoreInputSource()
|
|
||||||
}
|
|
||||||
try recorder.start(
|
try recorder.start(
|
||||||
chunkTiming: nativeSession ? .nativeDraft : .committed,
|
|
||||||
onChunk: { [weak self] chunk in
|
onChunk: { [weak self] chunk in
|
||||||
Task { @MainActor in self?.enqueue(chunk) }
|
Task { @MainActor in self?.enqueue(chunk) }
|
||||||
},
|
},
|
||||||
@@ -160,39 +125,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
DictationSounds.playStop()
|
DictationSounds.playStop()
|
||||||
setStatus("обрабатываю…")
|
setStatus("обрабатываю…")
|
||||||
do {
|
do {
|
||||||
let result = try recorder.stop()
|
if let final = try recorder.stop() {
|
||||||
if nativeSession, let fullSession = result.fullSessionURL {
|
enqueue(RecordedChunk(url: final, continuesPhrase: false))
|
||||||
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 {
|
} catch {
|
||||||
composition.cancel()
|
|
||||||
setStatus("ошибка")
|
setStatus("ошибка")
|
||||||
showError("Диктовка не удалась", error.localizedDescription)
|
showError("Диктовка не удалась", error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(_ chunk: RecordedChunk, isFinalSession: Bool = false) {
|
private func enqueue(_ chunk: RecordedChunk) {
|
||||||
pendingChunks.append(PendingChunk(
|
pendingChunks.append(PendingChunk(
|
||||||
url: chunk.url,
|
url: chunk.url,
|
||||||
continuesPhrase: chunk.continuesPhrase,
|
continuesPhrase: chunk.continuesPhrase
|
||||||
isFinalSession: isFinalSession
|
|
||||||
))
|
))
|
||||||
processNextChunk()
|
processNextChunk()
|
||||||
}
|
}
|
||||||
@@ -211,7 +157,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let wav = try AudioConverter.toWhisperWAV(recording)
|
let wav = try AudioConverter.toWhisperWAV(recording)
|
||||||
if chunk.isFinalSession { saveDiagnosticRecording(wav) }
|
saveDiagnosticRecording(wav)
|
||||||
defer {
|
defer {
|
||||||
try? FileManager.default.removeItem(at: recording)
|
try? FileManager.default.removeItem(at: recording)
|
||||||
try? FileManager.default.removeItem(at: wav)
|
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)
|
case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings)
|
||||||
}
|
}
|
||||||
let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary)
|
let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary)
|
||||||
if chunk.isFinalSession {
|
if !cleaned.isEmpty {
|
||||||
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(
|
let joined = TranscriptJoiner.prepare(
|
||||||
cleaned,
|
cleaned,
|
||||||
continuesPrevious: stitchNextChunk,
|
continuesPrevious: stitchNextChunk,
|
||||||
continuesNext: chunk.continuesPhrase,
|
continuesNext: chunk.continuesPhrase,
|
||||||
glossary: settings.glossary
|
glossary: settings.glossary
|
||||||
)
|
)
|
||||||
if !joined.isEmpty {
|
if !joined.isEmpty { try TextInserter.insert(joined + " ") }
|
||||||
if nativeSession {
|
|
||||||
nativeDraft += (nativeDraft.isEmpty ? "" : " ") + joined
|
|
||||||
_ = composition.updateDraft(nativeDraft)
|
|
||||||
} else {
|
|
||||||
try TextInserter.insert(joined + " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stitchNextChunk = chunk.continuesPhrase
|
stitchNextChunk = chunk.continuesPhrase
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if chunk.isFinalSession, !nativeDraft.isEmpty {
|
showError("Фрагмент диктовки не распознан", error.localizedDescription)
|
||||||
if !composition.commit(nativeDraft + " ") {
|
|
||||||
try? TextInserter.insert(nativeDraft + " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showError(
|
|
||||||
chunk.isFinalSession
|
|
||||||
? "Финальная диктовка не распознана"
|
|
||||||
: "Фрагмент диктовки не распознан",
|
|
||||||
error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
processingChunk = false
|
processingChunk = false
|
||||||
if pendingChunks.isEmpty {
|
if pendingChunks.isEmpty {
|
||||||
@@ -287,18 +208,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
stopRequested = false
|
stopRequested = false
|
||||||
lastMode = nil
|
lastMode = nil
|
||||||
stitchNextChunk = false
|
stitchNextChunk = false
|
||||||
nativeSession = false
|
|
||||||
nativeDraft = ""
|
|
||||||
setStatus("готово")
|
setStatus("готово")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func startLocal() { Task { await toggle(.local) } }
|
@objc private func startLocal() { Task { await toggle(.local) } }
|
||||||
@objc private func startCloud() { Task { await toggle(.cloud) } }
|
@objc private func startCloud() { Task { await toggle(.cloud) } }
|
||||||
@objc private func openSettings() { SettingsWindowController.shared.show() }
|
@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() {
|
@objc private func checkLocalEngine() {
|
||||||
do {
|
do {
|
||||||
try LocalWhisper.validate(settings: settings)
|
try LocalWhisper.validate(settings: settings)
|
||||||
@@ -307,10 +222,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
showError("Локальный движок не готов", error.localizedDescription)
|
showError("Локальный движок не готов", error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@objc private func quit() {
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
composition.cancel()
|
|
||||||
NSApp.terminate(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleWake() {
|
@objc private func handleWake() {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -6,37 +6,12 @@ struct RecordedChunk: Sendable {
|
|||||||
let continuesPhrase: Bool
|
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 {
|
final class AudioRecorder: @unchecked Sendable {
|
||||||
private var engine: AVAudioEngine?
|
private var engine: AVAudioEngine?
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var noSpeechTimeout: DispatchWorkItem?
|
private var noSpeechTimeout: DispatchWorkItem?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var outputFile: AVAudioFile?
|
private var outputFile: AVAudioFile?
|
||||||
private var sessionURL: URL?
|
|
||||||
private var sessionFile: AVAudioFile?
|
|
||||||
private var sessionFrames: AVAudioFramePosition = 0
|
|
||||||
private var currentFrames: AVAudioFramePosition = 0
|
private var currentFrames: AVAudioFramePosition = 0
|
||||||
private var currentChunkHasSpeech = false
|
private var currentChunkHasSpeech = false
|
||||||
private var heardSpeech = false
|
private var heardSpeech = false
|
||||||
@@ -46,10 +21,8 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
private var onChunk: (@Sendable (RecordedChunk) -> Void)?
|
private var onChunk: (@Sendable (RecordedChunk) -> Void)?
|
||||||
private var onSilence: (@Sendable () -> Void)?
|
private var onSilence: (@Sendable () -> Void)?
|
||||||
private var recordingFormat: AVAudioFormat?
|
private var recordingFormat: AVAudioFormat?
|
||||||
private var chunkTiming = ChunkTiming.committed
|
|
||||||
|
|
||||||
func start(
|
func start(
|
||||||
chunkTiming: ChunkTiming,
|
|
||||||
onChunk: @escaping @Sendable (RecordedChunk) -> Void,
|
onChunk: @escaping @Sendable (RecordedChunk) -> Void,
|
||||||
onSilence: @escaping @Sendable () -> Void
|
onSilence: @escaping @Sendable () -> Void
|
||||||
) throws {
|
) throws {
|
||||||
@@ -60,17 +33,8 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
self.onChunk = onChunk
|
self.onChunk = onChunk
|
||||||
self.onSilence = onSilence
|
self.onSilence = onSilence
|
||||||
self.chunkTiming = chunkTiming
|
|
||||||
recordingFormat = format
|
recordingFormat = format
|
||||||
do {
|
|
||||||
try beginSession(format: format)
|
|
||||||
try beginChunk(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
|
input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in
|
||||||
self?.consume(buffer, format: format)
|
self?.consume(buffer, format: format)
|
||||||
}
|
}
|
||||||
@@ -83,16 +47,13 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
engine.reset()
|
engine.reset()
|
||||||
self.engine = nil
|
self.engine = nil
|
||||||
if let currentURL { try? FileManager.default.removeItem(at: currentURL) }
|
if let currentURL { try? FileManager.default.removeItem(at: currentURL) }
|
||||||
if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) }
|
|
||||||
outputFile = nil
|
outputFile = nil
|
||||||
currentURL = nil
|
currentURL = nil
|
||||||
sessionFile = nil
|
|
||||||
sessionURL = nil
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() throws -> RecordingResult {
|
func stop() throws -> URL? {
|
||||||
noSpeechTimeout?.cancel()
|
noSpeechTimeout?.cancel()
|
||||||
noSpeechTimeout = nil
|
noSpeechTimeout = nil
|
||||||
if let engine {
|
if let engine {
|
||||||
@@ -105,29 +66,19 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
let finalChunkURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
|
let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
|
||||||
let fullSessionURL = sessionFrames > 0 && heardSpeech ? sessionURL : nil
|
if finalURL == nil, let currentURL {
|
||||||
if finalChunkURL == nil, let currentURL {
|
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
}
|
}
|
||||||
if fullSessionURL == nil, let sessionURL {
|
|
||||||
try? FileManager.default.removeItem(at: sessionURL)
|
|
||||||
}
|
|
||||||
outputFile = nil
|
outputFile = nil
|
||||||
currentURL = nil
|
currentURL = nil
|
||||||
sessionFile = nil
|
|
||||||
sessionURL = nil
|
|
||||||
sessionFrames = 0
|
|
||||||
currentFrames = 0
|
currentFrames = 0
|
||||||
currentChunkHasSpeech = false
|
currentChunkHasSpeech = false
|
||||||
heardSpeech = false
|
heardSpeech = false
|
||||||
silentFrames = 0
|
silentFrames = 0
|
||||||
chunkSilentFrames = 0
|
chunkSilentFrames = 0
|
||||||
silenceTriggered = false
|
silenceTriggered = false
|
||||||
return RecordingResult(
|
return finalURL
|
||||||
finalChunkURL: finalChunkURL,
|
|
||||||
fullSessionURL: fullSessionURL
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNoSpeechTimeout() {
|
private func scheduleNoSpeechTimeout() {
|
||||||
@@ -152,22 +103,12 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
chunkSilentFrames = 0
|
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) {
|
private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
guard let activeOutputFile = outputFile else { return }
|
guard let activeOutputFile = outputFile else { return }
|
||||||
try? activeOutputFile.write(from: buffer)
|
try? activeOutputFile.write(from: buffer)
|
||||||
try? sessionFile?.write(from: buffer)
|
|
||||||
currentFrames += AVAudioFramePosition(buffer.frameLength)
|
currentFrames += AVAudioFramePosition(buffer.frameLength)
|
||||||
sessionFrames += AVAudioFramePosition(buffer.frameLength)
|
|
||||||
|
|
||||||
let rms = bufferRMS(buffer)
|
let rms = bufferRMS(buffer)
|
||||||
if rms > 0.006 {
|
if rms > 0.006 {
|
||||||
@@ -192,9 +133,9 @@ final class AudioRecorder: @unchecked Sendable {
|
|||||||
// Keep enough context for Whisper, but insert completed phrases while
|
// Keep enough context for Whisper, but insert completed phrases while
|
||||||
// the user is still dictating. Prefer a short natural pause; cap a
|
// the user is still dictating. Prefer a short natural pause; cap a
|
||||||
// continuous phrase so the input never stays blank indefinitely.
|
// continuous phrase so the input never stays blank indefinitely.
|
||||||
let minimumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.minimumDuration)
|
let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5)
|
||||||
let pauseBoundary = AVAudioFramePosition(format.sampleRate * chunkTiming.pauseDuration)
|
let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0)
|
||||||
let maximumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.maximumDuration)
|
let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0)
|
||||||
let reachedPause =
|
let reachedPause =
|
||||||
currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary
|
currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary
|
||||||
let reachedMaximum = currentFrames >= maximumChunk
|
let reachedMaximum = currentFrames >= maximumChunk
|
||||||
|
|||||||
@@ -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<CFString>
|
|
||||||
.fromOpaque(rawValue)
|
|
||||||
.takeUnretainedValue() as String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ final class SettingsWindowController: NSWindowController {
|
|||||||
grid.column(at: 0).xPlacement = .trailing
|
grid.column(at: 0).xPlacement = .trailing
|
||||||
modelPopup.addItems(withTitles: ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"])
|
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.textColor = .secondaryLabelColor
|
||||||
note.translatesAutoresizingMaskIntoConstraints = false
|
note.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let save = NSButton(title: "Сохранить", target: self, action: #selector(save))
|
let save = NSButton(title: "Сохранить", target: self, action: #selector(save))
|
||||||
|
|||||||
@@ -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) {}
|
|
||||||
+1
-10
@@ -3,9 +3,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROJECT_DIR=${0:A:h:h}
|
PROJECT_DIR=${0:A:h:h}
|
||||||
APP_DIR="$PROJECT_DIR/dist/WhisperKey.app"
|
APP_DIR="$PROJECT_DIR/dist/WhisperKey.app"
|
||||||
INPUT_APP_DIR="$PROJECT_DIR/dist/WhisperKeyInput.app"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
rm -rf "$PROJECT_DIR/dist/WhisperKeyInput.app"
|
||||||
swift build -c release
|
swift build -c release
|
||||||
|
|
||||||
rm -rf "$APP_DIR"
|
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"
|
cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist"
|
||||||
codesign --force --deep --sign - "$APP_DIR"
|
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 "$APP_DIR"
|
||||||
echo "$INPUT_APP_DIR"
|
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ pkill -x WhisperKey 2>/dev/null || true
|
|||||||
ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app"
|
ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app"
|
||||||
|
|
||||||
INPUT_METHOD_DIR="$HOME/Library/Input Methods"
|
INPUT_METHOD_DIR="$HOME/Library/Input Methods"
|
||||||
mkdir -p "$INPUT_METHOD_DIR"
|
|
||||||
pkill -x WhisperKeyInput 2>/dev/null || true
|
pkill -x WhisperKeyInput 2>/dev/null || true
|
||||||
rm -rf "$INPUT_METHOD_DIR/WhisperKeyInput.app"
|
rm -rf "$INPUT_METHOD_DIR/WhisperKeyInput.app"
|
||||||
ditto "$PROJECT_DIR/dist/WhisperKeyInput.app" "$INPUT_METHOD_DIR/WhisperKeyInput.app"
|
killall TextInputMenuAgent 2>/dev/null || true
|
||||||
open -gja "$INPUT_METHOD_DIR/WhisperKeyInput.app"
|
|
||||||
|
|
||||||
LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents"
|
LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents"
|
||||||
LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist"
|
LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist"
|
||||||
@@ -24,4 +22,3 @@ launchctl bootstrap "gui/$(id -u)" "$LOGIN_AGENT"
|
|||||||
open "/Applications/WhisperKey.app"
|
open "/Applications/WhisperKey.app"
|
||||||
|
|
||||||
echo "Installed: /Applications/WhisperKey.app"
|
echo "Installed: /Applications/WhisperKey.app"
|
||||||
echo "Installed: $INPUT_METHOD_DIR/WhisperKeyInput.app"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user