chore: adiciona .gitignore e commit.command
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
||||
import Combine
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import Sparkle
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CheckForUpdatesViewModel {
|
||||
init() {
|
||||
anyCancellable = controller.updater.publisher(for: \.canCheckForUpdates)
|
||||
.sink(receiveValue: { self.canCheckForUpdates = $0 })
|
||||
}
|
||||
|
||||
static let shared = CheckForUpdatesViewModel()
|
||||
|
||||
let controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
var anyCancellable: AnyCancellable?
|
||||
|
||||
var canCheckForUpdates = false
|
||||
|
||||
func checkForUpdates() {
|
||||
controller.updater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckForUpdatesView: View {
|
||||
@State var viewModel = CheckForUpdatesViewModel.shared
|
||||
@ObserveInjection var inject
|
||||
|
||||
var body: some View {
|
||||
Button("Check for Updates…", action: viewModel.checkForUpdates)
|
||||
.disabled(!viewModel.canCheckForUpdates)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import HexCore
|
||||
import OSLog
|
||||
|
||||
enum DiagnosticsLogging {
|
||||
private static var isBootstrapped = false
|
||||
private static let logger = Logger(subsystem: HexLog.subsystem, category: "Diagnostics")
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
guard !isBootstrapped else { return }
|
||||
logger.notice("Diagnostics logging initialized")
|
||||
isBootstrapped = true
|
||||
}
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import Sparkle
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct HexApp: App {
|
||||
static let appStore = Store(initialState: AppFeature.State()) {
|
||||
AppFeature()
|
||||
}
|
||||
|
||||
@NSApplicationDelegateAdaptor(HexAppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
MenuBarCopyLastTranscriptButton()
|
||||
|
||||
Button("Settings…") {
|
||||
appDelegate.presentSettingsView()
|
||||
}.keyboardShortcut(",")
|
||||
|
||||
CheckForUpdatesView()
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit Hex") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}.keyboardShortcut("q")
|
||||
} label: {
|
||||
if let image = NSImage(named: "HexIcon").map({
|
||||
let ratio = $0.size.height / $0.size.width
|
||||
$0.size.height = 18
|
||||
$0.size.width = 18 / ratio
|
||||
return $0
|
||||
}) {
|
||||
Image(nsImage: image)
|
||||
} else {
|
||||
Image(systemName: "hexagon")
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView()
|
||||
|
||||
Button("Settings…") {
|
||||
appDelegate.presentSettingsView()
|
||||
}.keyboardShortcut(",")
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .help) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
private let appLogger = HexLog.app
|
||||
private let cacheLogger = HexLog.caches
|
||||
|
||||
class HexAppDelegate: NSObject, NSApplicationDelegate {
|
||||
var invisibleWindow: InvisibleWindow?
|
||||
var settingsWindow: NSWindow?
|
||||
var statusItem: NSStatusItem!
|
||||
private var launchedAtLogin = false
|
||||
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.recording) var recording
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
DiagnosticsLogging.bootstrapIfNeeded()
|
||||
// Ensure Parakeet/FluidAudio caches live under Application Support, not ~/.cache
|
||||
configureLocalCaches()
|
||||
if isTesting {
|
||||
appLogger.debug("Running in testing mode")
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await soundEffect.preloadSounds()
|
||||
await soundEffect.setEnabled(hexSettings.soundEffectsEnabled)
|
||||
}
|
||||
launchedAtLogin = wasLaunchedAtLogin()
|
||||
appLogger.info("Application did finish launching")
|
||||
appLogger.notice("launchedAtLogin = \(self.launchedAtLogin)")
|
||||
|
||||
// Set activation policy first
|
||||
updateAppMode()
|
||||
|
||||
// Add notification observer
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppModeUpdate),
|
||||
name: .updateAppMode,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePresentSettingsWindow),
|
||||
name: .presentSettingsWindow,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Start long-running app effects (global hotkeys, permissions, etc.)
|
||||
startLifecycleTasksIfNeeded()
|
||||
|
||||
// Then present main views
|
||||
presentMainView()
|
||||
|
||||
guard shouldOpenForegroundUIOnLaunch else {
|
||||
appLogger.notice("Suppressing foreground windows for login launch")
|
||||
return
|
||||
}
|
||||
|
||||
presentSettingsView()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private var shouldOpenForegroundUIOnLaunch: Bool {
|
||||
// When Hex launches at login, stay quietly in the menu bar regardless of
|
||||
// the dock-icon preference. Users who enabled "Open on Login" expect a
|
||||
// background launch; the Settings window can be opened later from the
|
||||
// menu bar item or ⌘, when needed.
|
||||
!launchedAtLogin
|
||||
}
|
||||
|
||||
private func wasLaunchedAtLogin() -> Bool {
|
||||
guard let event = NSAppleEventManager.shared().currentAppleEvent else {
|
||||
return false
|
||||
}
|
||||
|
||||
return event.eventID == AEEventID(kAEOpenApplication)
|
||||
&& event.paramDescriptor(forKeyword: AEKeyword(keyAEPropData))?.enumCodeValue == AEEventClass(keyAELaunchedAsLogInItem)
|
||||
}
|
||||
|
||||
private func startLifecycleTasksIfNeeded() {
|
||||
Task { @MainActor in
|
||||
await HexApp.appStore.send(.task).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets XDG_CACHE_HOME so FluidAudio stores models under our app's
|
||||
/// Application Support folder, keeping everything in one place.
|
||||
private func configureLocalCaches() {
|
||||
do {
|
||||
let cache = try URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
|
||||
setenv("XDG_CACHE_HOME", cache.path, 1)
|
||||
cacheLogger.info("XDG_CACHE_HOME set to \(cache.path)")
|
||||
} catch {
|
||||
cacheLogger.error("Failed to configure local caches: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func presentMainView() {
|
||||
guard invisibleWindow == nil else {
|
||||
return
|
||||
}
|
||||
let transcriptionStore = HexApp.appStore.scope(state: \.transcription, action: \.transcription)
|
||||
let transcriptionView = TranscriptionView(store: transcriptionStore).padding().padding(.top).padding(.top)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
invisibleWindow = InvisibleWindow.fromView(transcriptionView)
|
||||
invisibleWindow?.orderFrontRegardless()
|
||||
}
|
||||
|
||||
func presentSettingsView() {
|
||||
if let settingsWindow = settingsWindow {
|
||||
settingsWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let settingsView = AppView(store: HexApp.appStore)
|
||||
let settingsWindow = NSWindow(
|
||||
contentRect: .init(x: 0, y: 0, width: 700, height: 700),
|
||||
styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
settingsWindow.titleVisibility = .visible
|
||||
settingsWindow.contentView = NSHostingView(rootView: settingsView)
|
||||
settingsWindow.isReleasedWhenClosed = false
|
||||
settingsWindow.minSize = .init(width: 620, height: 560)
|
||||
settingsWindow.setFrameAutosaveName("Settings")
|
||||
settingsWindow.center()
|
||||
settingsWindow.toolbarStyle = NSWindow.ToolbarStyle.unified
|
||||
settingsWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.settingsWindow = settingsWindow
|
||||
}
|
||||
|
||||
@objc private func handleAppModeUpdate() {
|
||||
Task {
|
||||
await updateAppMode()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePresentSettingsWindow() {
|
||||
presentSettingsView()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateAppMode() {
|
||||
appLogger.debug("showDockIcon = \(self.hexSettings.showDockIcon)")
|
||||
if self.hexSettings.showDockIcon {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
} else {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
||||
presentSettingsView()
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_: Notification) {
|
||||
// Wait for audio teardown before the process exits: a fire-and-forget Task here
|
||||
// raced process exit, crashing inside AVAudioEngine teardown while tap callbacks
|
||||
// were still in flight (#245). Pump the main run loop while waiting instead of
|
||||
// blocking outright - cleanup() hops to the main actor/main queue (media-key
|
||||
// resume, Core Audio listener removal), which a blocked main thread would deadlock.
|
||||
let recording = recording
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task.detached {
|
||||
await recording.cleanup()
|
||||
semaphore.signal()
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while semaphore.wait(timeout: .now()) == .timedOut {
|
||||
guard Date() < deadline else {
|
||||
appLogger.error("Recording cleanup timed out during app termination")
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import AppKit
|
||||
import HexCore
|
||||
import Sauce
|
||||
|
||||
struct MenuBarCopyLastTranscriptButton: View {
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
|
||||
var body: some View {
|
||||
let lastText = transcriptionHistory.history.first?.text
|
||||
|
||||
let button = Button("Paste Last Transcript") {
|
||||
if let text = lastText {
|
||||
Task { await pasteboard.paste(text) }
|
||||
}
|
||||
}
|
||||
.disabled(lastText == nil)
|
||||
|
||||
if let hotkey = hexSettings.pasteLastTranscriptHotkey,
|
||||
let key = hotkey.key,
|
||||
let keyEquivalent = toKeyEquivalent(key) {
|
||||
button.keyboardShortcut(keyEquivalent, modifiers: toEventModifiers(hotkey.modifiers))
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
private func toKeyEquivalent(_ key: Key) -> KeyEquivalent? {
|
||||
switch key.rawValue {
|
||||
case "a": return "a"
|
||||
case "b": return "b"
|
||||
case "c": return "c"
|
||||
case "d": return "d"
|
||||
case "e": return "e"
|
||||
case "f": return "f"
|
||||
case "g": return "g"
|
||||
case "h": return "h"
|
||||
case "i": return "i"
|
||||
case "j": return "j"
|
||||
case "k": return "k"
|
||||
case "l": return "l"
|
||||
case "m": return "m"
|
||||
case "n": return "n"
|
||||
case "o": return "o"
|
||||
case "p": return "p"
|
||||
case "q": return "q"
|
||||
case "r": return "r"
|
||||
case "s": return "s"
|
||||
case "t": return "t"
|
||||
case "u": return "u"
|
||||
case "v": return "v"
|
||||
case "w": return "w"
|
||||
case "x": return "x"
|
||||
case "y": return "y"
|
||||
case "z": return "z"
|
||||
case "0": return "0"
|
||||
case "1": return "1"
|
||||
case "2": return "2"
|
||||
case "3": return "3"
|
||||
case "4": return "4"
|
||||
case "5": return "5"
|
||||
case "6": return "6"
|
||||
case "7": return "7"
|
||||
case "8": return "8"
|
||||
case "9": return "9"
|
||||
case ",": return ","
|
||||
case ".": return "."
|
||||
case "/": return "/"
|
||||
case "\\": return "\\"
|
||||
case "'": return "'"
|
||||
case ";": return ";"
|
||||
case "[": return "["
|
||||
case "]": return "]"
|
||||
case "-": return "-"
|
||||
case "=": return "="
|
||||
case "`": return "`"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func toEventModifiers(_ modifiers: Modifiers) -> SwiftUI.EventModifiers {
|
||||
var result: SwiftUI.EventModifiers = []
|
||||
if modifiers.contains(kind: .command) { result.insert(.command) }
|
||||
if modifiers.contains(kind: .option) { result.insert(.option) }
|
||||
if modifiers.contains(kind: .shift) { result.insert(.shift) }
|
||||
if modifiers.contains(kind: .control) { result.insert(.control) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuBarCopyLastTranscriptButton()
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension NSNotification.Name {
|
||||
/// Posted when app mode settings change (dock icon visibility, etc.)
|
||||
static let updateAppMode = NSNotification.Name("UpdateAppMode")
|
||||
static let presentSettingsWindow = NSNotification.Name("PresentSettingsWindow")
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Executable
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"gray:0.19846,1.00000",
|
||||
"srgb:0.00000,0.00000,0.00000,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Image.png",
|
||||
"name" : "Image",
|
||||
"position" : {
|
||||
"scale" : 0.5,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "hex 1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "hex 2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="1488" height="1672" viewBox="0 0 1488 1672" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M709.727 10.3496L40.6947 387.748C15.5519 401.931 0 428.56 0 457.427V1209.64C0 1238.35 15.3893 1264.86 40.3236 1279.11L709.353 1661.22C734.093 1675.35 764.481 1675.26 789.134 1660.97L1448.1 1279.19C1472.8 1264.88 1488 1238.51 1488 1209.97V457.098C1488 428.407 1472.64 401.912 1447.73 387.662L788.764 10.5921C764.301 -3.40629 734.276 -3.49841 709.727 10.3496ZM709.112 114.321L131.279 439.924C106.119 454.101 90.5526 480.74 90.5526 509.62V1158.09C90.5526 1186.82 105.956 1213.34 130.908 1227.58L708.739 1557.25C733.462 1571.36 763.819 1571.26 788.455 1557L1357.52 1227.66C1382.23 1213.36 1397.45 1186.97 1397.45 1158.42V509.292C1397.45 480.587 1382.07 454.082 1357.15 439.837L788.085 114.563C763.638 100.589 733.645 100.497 709.112 114.321Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M708.005 299.036L292.852 532.609C267.665 546.78 252.079 573.432 252.079 602.332V1066.53C252.079 1095.28 267.503 1121.82 292.481 1136.05L707.631 1372.54C732.331 1386.61 762.642 1386.51 787.255 1372.29L1195.95 1136.13C1220.68 1121.83 1235.92 1095.43 1235.92 1066.86V602.003C1235.92 573.279 1220.52 546.76 1195.57 532.522L786.886 299.278C762.462 285.339 732.513 285.247 708.005 299.036ZM707.267 400.72L383.315 583.532C358.178 597.716 342.631 624.341 342.631 653.204V1016.29C342.631 1045 358.016 1071.51 382.943 1085.75L706.894 1270.85C731.64 1284.99 762.04 1284.9 786.698 1270.6L1105.48 1085.84C1130.17 1071.53 1145.37 1045.16 1145.37 1016.62V652.875C1145.37 624.188 1130.01 597.697 1105.11 583.446L786.328 400.963C761.859 386.956 731.823 386.863 707.267 400.72Z" fill="white"/>
|
||||
<path d="M538.299 700.708L730.259 592.584C739.763 587.23 751.381 587.266 760.852 592.677L949.866 700.674C959.525 706.193 965.487 716.465 965.487 727.59V943.013C965.487 954.079 959.589 964.305 950.01 969.847L760.995 1079.19C751.451 1084.72 739.692 1084.75 730.114 1079.29L538.156 969.814C528.484 964.298 522.513 954.02 522.513 942.886V727.718C522.513 716.525 528.547 706.201 538.299 700.708Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="1488" height="1672" viewBox="0 0 1488 1672" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M709.727 10.3496L40.6947 387.748C15.5519 401.931 0 428.56 0 457.427V1209.64C0 1238.35 15.3893 1264.86 40.3236 1279.11L709.353 1661.22C734.093 1675.35 764.481 1675.26 789.134 1660.97L1448.1 1279.19C1472.8 1264.88 1488 1238.51 1488 1209.97V457.098C1488 428.407 1472.64 401.912 1447.73 387.662L788.764 10.5921C764.301 -3.40629 734.276 -3.49841 709.727 10.3496ZM709.112 114.321L131.279 439.924C106.119 454.101 90.5526 480.74 90.5526 509.62V1158.09C90.5526 1186.82 105.956 1213.34 130.908 1227.58L708.739 1557.25C733.462 1571.36 763.819 1571.26 788.455 1557L1357.52 1227.66C1382.23 1213.36 1397.45 1186.97 1397.45 1158.42V509.292C1397.45 480.587 1382.07 454.082 1357.15 439.837L788.085 114.563C763.638 100.589 733.645 100.497 709.112 114.321Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M708.005 299.036L292.852 532.609C267.665 546.78 252.079 573.432 252.079 602.332V1066.53C252.079 1095.28 267.503 1121.82 292.481 1136.05L707.631 1372.54C732.331 1386.61 762.642 1386.51 787.255 1372.29L1195.95 1136.13C1220.68 1121.83 1235.92 1095.43 1235.92 1066.86V602.003C1235.92 573.279 1220.52 546.76 1195.57 532.522L786.886 299.278C762.462 285.339 732.513 285.247 708.005 299.036ZM707.267 400.72L383.315 583.532C358.178 597.716 342.631 624.341 342.631 653.204V1016.29C342.631 1045 358.016 1071.51 382.943 1085.75L706.894 1270.85C731.64 1284.99 762.04 1284.9 786.698 1270.6L1105.48 1085.84C1130.17 1071.53 1145.37 1045.16 1145.37 1016.62V652.875C1145.37 624.188 1130.01 597.697 1105.11 583.446L786.328 400.963C761.859 386.956 731.823 386.863 707.267 400.72Z" fill="white"/>
|
||||
<path d="M538.299 700.708L730.259 592.584C739.763 587.23 751.381 587.266 760.852 592.677L949.866 700.674C959.525 706.193 965.487 716.465 965.487 727.59V943.013C965.487 954.079 959.589 964.305 950.01 969.847L760.995 1079.19C751.451 1084.72 739.692 1084.75 730.114 1079.29L538.156 969.814C528.484 964.298 522.513 954.02 522.513 942.886V727.718C522.513 716.525 528.547 706.201 538.299 700.708Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
+611
@@ -0,0 +1,611 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Carbon
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import IOKit
|
||||
import IOKit.hidsystem
|
||||
import Sauce
|
||||
|
||||
private let logger = HexLog.keyEvent
|
||||
|
||||
struct KeyEventMonitorToken: Sendable {
|
||||
private let cancelHandler: @Sendable () -> Void
|
||||
|
||||
init(cancel: @escaping @Sendable () -> Void) {
|
||||
self.cancelHandler = cancel
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelHandler()
|
||||
}
|
||||
|
||||
static let noop = KeyEventMonitorToken(cancel: {})
|
||||
}
|
||||
|
||||
public extension KeyEvent {
|
||||
init(cgEvent: CGEvent, type: CGEventType, isFnPressed: Bool) {
|
||||
let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode))
|
||||
// Accessing keyboard layout / input source via Sauce must be on main thread.
|
||||
let key: Key?
|
||||
if cgEvent.type == .keyDown {
|
||||
if Thread.isMainThread {
|
||||
key = Sauce.shared.key(for: keyCode)
|
||||
} else {
|
||||
key = DispatchQueue.main.sync { Sauce.shared.key(for: keyCode) }
|
||||
}
|
||||
} else {
|
||||
key = nil
|
||||
}
|
||||
|
||||
var modifiers = Modifiers.from(carbonFlags: cgEvent.flags)
|
||||
if !isFnPressed {
|
||||
modifiers = modifiers.removing(kind: .fn)
|
||||
}
|
||||
self.init(key: key, modifiers: modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
struct KeyEventMonitorClient {
|
||||
var listenForKeyPress: @Sendable () async -> AsyncThrowingStream<KeyEvent, Error> = {
|
||||
AsyncThrowingStream { _ in }
|
||||
}
|
||||
var handleKeyEvent: @Sendable (@Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop }
|
||||
var handleInputEvent: @Sendable (@Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken = { _ in .noop }
|
||||
var startMonitoring: @Sendable () async -> Void = {}
|
||||
var stopMonitoring: @Sendable () -> Void = {}
|
||||
}
|
||||
|
||||
extension KeyEventMonitorClient: DependencyKey {
|
||||
static var liveValue: KeyEventMonitorClient {
|
||||
let live = KeyEventMonitorClientLive()
|
||||
return KeyEventMonitorClient(
|
||||
listenForKeyPress: {
|
||||
live.listenForKeyPress()
|
||||
},
|
||||
handleKeyEvent: { handler in
|
||||
live.handleKeyEvent(handler)
|
||||
},
|
||||
handleInputEvent: { handler in
|
||||
live.handleInputEvent(handler)
|
||||
},
|
||||
startMonitoring: {
|
||||
live.startMonitoring()
|
||||
},
|
||||
stopMonitoring: {
|
||||
live.stopMonitoring()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var keyEventMonitor: KeyEventMonitorClient {
|
||||
get { self[KeyEventMonitorClient.self] }
|
||||
set { self[KeyEventMonitorClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
class KeyEventMonitorClientLive {
|
||||
private var eventTapPort: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
private var continuations: [UUID: @Sendable (KeyEvent) -> Bool] = [:]
|
||||
private var inputContinuations: [UUID: @Sendable (InputEvent) -> Bool] = [:]
|
||||
private let queue = DispatchQueue(label: "com.kitlangton.Hex.KeyEventMonitor", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<Void>()
|
||||
private var isMonitoring = false
|
||||
private var wantsMonitoring = false
|
||||
private var accessibilityTrusted = false
|
||||
private var inputMonitoringTrusted = false
|
||||
/// Set when key events are observed arriving at the tap. Key events only flow when Input
|
||||
/// Monitoring is genuinely granted, so this overrides stale `IOHIDCheckAccess` denials (#250).
|
||||
private var inputMonitoringProvenByEvents = false
|
||||
private var trustMonitorTask: Task<Void, Never>?
|
||||
private var systemEventObservers: [NSObjectProtocol] = []
|
||||
private var isFnPressed = false
|
||||
private var hasPromptedForAccessibilityTrust = false
|
||||
private let accessibilityTrustProvider: @Sendable () -> Bool
|
||||
private let accessibilityTrustPrompt: @Sendable () -> Bool
|
||||
private let inputMonitoringTrustProvider: @Sendable () -> Bool
|
||||
@Shared(.hotkeyPermissionState) private var hotkeyPermissionState: HotkeyPermissionState
|
||||
|
||||
private let trustCheckIntervalNanoseconds: UInt64 = 100_000_000 // 100ms
|
||||
|
||||
init(
|
||||
accessibilityTrustProvider: @escaping @Sendable () -> Bool = {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
return AXIsProcessTrustedWithOptions([promptKey: false] as CFDictionary)
|
||||
},
|
||||
accessibilityTrustPrompt: @escaping @Sendable () -> Bool = {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
return AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
|
||||
},
|
||||
inputMonitoringTrustProvider: @escaping @Sendable () -> Bool = {
|
||||
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
|
||||
}
|
||||
) {
|
||||
self.accessibilityTrustProvider = accessibilityTrustProvider
|
||||
self.accessibilityTrustPrompt = accessibilityTrustPrompt
|
||||
self.inputMonitoringTrustProvider = inputMonitoringTrustProvider
|
||||
queue.setSpecific(key: queueSpecificKey, value: ())
|
||||
logger.info("Initializing HotKeyClient with CGEvent tap.")
|
||||
registerSystemEventObservers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
for observer in systemEventObservers {
|
||||
center.removeObserver(observer)
|
||||
}
|
||||
self.stopMonitoring()
|
||||
}
|
||||
|
||||
private var hasHandlers: Bool {
|
||||
readState { !(continuations.isEmpty && inputContinuations.isEmpty) }
|
||||
}
|
||||
|
||||
func readState<Value>(_ operation: () -> Value) -> Value {
|
||||
// Handler registration performs permission checks from a barrier block on this queue.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
return operation()
|
||||
}
|
||||
return queue.sync(execute: operation)
|
||||
}
|
||||
|
||||
private func setMonitoringIntent(_ value: Bool) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.wantsMonitoring = value
|
||||
}
|
||||
}
|
||||
|
||||
private func desiredMonitoringState() -> Bool {
|
||||
// Intentionally not gated on `inputMonitoringTrusted`: `IOHIDCheckAccess` is notorious for
|
||||
// returning stale denials (after sleep, MDM re-logins, or OS updates) while events still
|
||||
// flow, and tearing the tap down on that signal killed working hotkeys (#250). The tap only
|
||||
// needs Accessibility to exist; without Input Monitoring, macOS simply withholds key events
|
||||
// (modifiers still arrive), and creating the tap is what triggers the permission prompt.
|
||||
readState {
|
||||
wantsMonitoring
|
||||
&& accessibilityTrusted
|
||||
&& !(continuations.isEmpty && inputContinuations.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide a stream of key events.
|
||||
func listenForKeyPress() -> AsyncThrowingStream<KeyEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = { event in
|
||||
continuation.yield(event)
|
||||
return false
|
||||
}
|
||||
let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty
|
||||
|
||||
// Start monitoring if this is the first subscription
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on cancellation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
self?.removeHandlerContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeHandlerContinuation(uuid: UUID) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = nil
|
||||
if self.continuations.isEmpty && self.inputContinuations.isEmpty {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInputContinuation(uuid: UUID) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.inputContinuations[uuid] = nil
|
||||
if self.continuations.isEmpty && self.inputContinuations.isEmpty {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startMonitoring() {
|
||||
setMonitoringIntent(true)
|
||||
startTrustMonitorIfNeeded()
|
||||
refreshTrustedFlag(promptIfUntrusted: true)
|
||||
Task { [weak self] in
|
||||
await self?.refreshMonitoringState(reason: "startMonitoring")
|
||||
}
|
||||
}
|
||||
// TODO: Handle removing the handler from the continuations on deinit/cancellation
|
||||
func handleKeyEvent(_ handler: @Sendable @escaping (KeyEvent) -> Bool) -> KeyEventMonitorToken {
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.continuations[uuid] = handler
|
||||
let shouldStart = self.continuations.count == 1 && self.inputContinuations.isEmpty
|
||||
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventMonitorToken { [weak self] in
|
||||
self?.removeHandlerContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func handleInputEvent(_ handler: @Sendable @escaping (InputEvent) -> Bool) -> KeyEventMonitorToken {
|
||||
let uuid = UUID()
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.inputContinuations[uuid] = handler
|
||||
let shouldStart = self.inputContinuations.count == 1 && self.continuations.isEmpty
|
||||
|
||||
if shouldStart {
|
||||
self.startMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventMonitorToken { [weak self] in
|
||||
self?.removeInputContinuation(uuid: uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
setMonitoringIntent(false)
|
||||
Task { [weak self] in
|
||||
await self?.refreshMonitoringState(reason: "stopMonitoring")
|
||||
}
|
||||
cancelTrustMonitorIfNeeded()
|
||||
}
|
||||
|
||||
private func startTrustMonitorIfNeeded() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.trustMonitorTask == nil else { return }
|
||||
self.trustMonitorTask = Task { [weak self] in
|
||||
await self?.watchPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelTrustMonitorIfNeeded() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.wantsMonitoring else { return }
|
||||
self.trustMonitorTask?.cancel()
|
||||
self.trustMonitorTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// no separate helper; handled inline above
|
||||
|
||||
private func watchPermissions() async {
|
||||
var last = (
|
||||
accessibility: currentAccessibilityTrust(),
|
||||
input: currentInputMonitoringTrust()
|
||||
)
|
||||
await handlePermissionChange(accessibility: last.accessibility, input: last.input, reason: "initial")
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: trustCheckIntervalNanoseconds)
|
||||
let current = (
|
||||
accessibility: currentAccessibilityTrust(),
|
||||
input: currentInputMonitoringTrust()
|
||||
)
|
||||
|
||||
if current.accessibility != last.accessibility || current.input != last.input {
|
||||
let combinedBefore = last.accessibility && last.input
|
||||
let combinedAfter = current.accessibility && current.input
|
||||
let reason: String
|
||||
if combinedAfter && !combinedBefore {
|
||||
reason = "regained"
|
||||
} else if !combinedAfter && combinedBefore {
|
||||
reason = "revoked"
|
||||
} else {
|
||||
reason = "updated"
|
||||
}
|
||||
await handlePermissionChange(accessibility: current.accessibility, input: current.input, reason: reason)
|
||||
last = current
|
||||
} else if current.accessibility {
|
||||
await ensureTapIsRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionChange(accessibility: Bool, input: Bool, reason: String) async {
|
||||
setPermissionFlags(accessibility: accessibility, input: input)
|
||||
logger.notice("Permission update: accessibility=\(accessibility), inputMonitoring=\(input), reason=\(reason)")
|
||||
if accessibility && input {
|
||||
logger.notice("Keyboard monitoring permissions granted (\(reason)).")
|
||||
} else {
|
||||
if !accessibility {
|
||||
logger.error("Accessibility permission missing (\(reason)); suspending tap.")
|
||||
}
|
||||
if !input {
|
||||
logger.error("Input Monitoring permission missing (\(reason)); keyed hotkeys may not fire until it is granted. Tap stays alive in case the check is stale.")
|
||||
}
|
||||
}
|
||||
await refreshMonitoringState(reason: "trust_\(reason)")
|
||||
}
|
||||
|
||||
private func ensureTapIsRunning() async {
|
||||
guard desiredMonitoringState() else { return }
|
||||
await activateTapOnMain(reason: "watchdog_keepalive")
|
||||
}
|
||||
|
||||
private func refreshMonitoringState(reason: String) async {
|
||||
let shouldMonitor = desiredMonitoringState()
|
||||
if shouldMonitor {
|
||||
await activateTapOnMain(reason: reason)
|
||||
} else {
|
||||
await deactivateTapOnMain(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func setPermissionFlags(accessibility: Bool, input: Bool) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.accessibilityTrusted = accessibility
|
||||
self?.inputMonitoringTrusted = input
|
||||
}
|
||||
recordSharedPermissionState(accessibility: accessibility, input: input)
|
||||
}
|
||||
|
||||
private func recordSharedPermissionState(accessibility: Bool, input: Bool) {
|
||||
$hotkeyPermissionState.withLock {
|
||||
$0.accessibility = accessibility ? .granted : .denied
|
||||
$0.inputMonitoring = input ? .granted : .denied
|
||||
$0.lastUpdated = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func activateTapOnMain(reason: String) async {
|
||||
await MainActor.run {
|
||||
self.activateTapIfNeeded(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateTapOnMain(reason: String) async {
|
||||
await MainActor.run {
|
||||
self.deactivateTap(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func activateTapIfNeeded(reason: String) {
|
||||
if isMonitoring {
|
||||
// The 100ms permission watchdog lands here while healthy; use it to revive taps that
|
||||
// macOS disabled without sending a tapDisabled event (observed after sleep, #250).
|
||||
if let eventTapPort, !CGEvent.tapIsEnabled(tap: eventTapPort) {
|
||||
CGEvent.tapEnable(tap: eventTapPort, enable: true)
|
||||
logger.notice("Re-enabled event tap that was silently disabled (reason: \(reason)).")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard hasHandlers else { return }
|
||||
|
||||
let accessibilityTrusted = currentAccessibilityTrust()
|
||||
let inputMonitoringTrusted = currentInputMonitoringTrust()
|
||||
setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted)
|
||||
guard accessibilityTrusted else {
|
||||
logger.error("Cannot start key event monitoring (reason: \(reason)); accessibility permission is not granted.")
|
||||
return
|
||||
}
|
||||
|
||||
if !inputMonitoringTrusted {
|
||||
logger.notice("Input Monitoring not yet granted; creating event tap will trigger permission prompt (reason: \(reason)).")
|
||||
}
|
||||
|
||||
let eventMask =
|
||||
((1 << CGEventType.keyDown.rawValue)
|
||||
| (1 << CGEventType.keyUp.rawValue)
|
||||
| (1 << CGEventType.flagsChanged.rawValue)
|
||||
| (1 << CGEventType.leftMouseDown.rawValue)
|
||||
| (1 << CGEventType.rightMouseDown.rawValue)
|
||||
| (1 << CGEventType.otherMouseDown.rawValue))
|
||||
|
||||
guard
|
||||
let eventTap = CGEvent.tapCreate(
|
||||
tap: .cghidEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(eventMask),
|
||||
callback: { _, type, cgEvent, userInfo in
|
||||
guard
|
||||
let hotKeyClientLive = Unmanaged<KeyEventMonitorClientLive>
|
||||
.fromOpaque(userInfo!)
|
||||
.takeUnretainedValue() as KeyEventMonitorClientLive?
|
||||
else {
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout {
|
||||
hotKeyClientLive.handleTapDisabledEvent(type)
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
// An event arriving at the tap is authoritative proof the underlying permission is
|
||||
// granted. Never drop delivered events because a cached permission check went
|
||||
// stale (#250) — that turned recoverable TCC hiccups into dead hotkeys.
|
||||
hotKeyClientLive.noteEventDelivered(type: type)
|
||||
|
||||
if type == .leftMouseDown || type == .rightMouseDown || type == .otherMouseDown {
|
||||
_ = hotKeyClientLive.processInputEvent(.mouseClick)
|
||||
return Unmanaged.passUnretained(cgEvent)
|
||||
}
|
||||
|
||||
hotKeyClientLive.updateFnStateIfNeeded(type: type, cgEvent: cgEvent)
|
||||
|
||||
let keyEvent = KeyEvent(cgEvent: cgEvent, type: type, isFnPressed: hotKeyClientLive.isFnPressed)
|
||||
let handledByKeyHandler = hotKeyClientLive.processKeyEvent(keyEvent)
|
||||
let handledByInputHandler = hotKeyClientLive.processInputEvent(.keyboard(keyEvent))
|
||||
|
||||
return (handledByKeyHandler || handledByInputHandler) ? nil : Unmanaged.passUnretained(cgEvent)
|
||||
},
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
)
|
||||
else {
|
||||
logger.error("Failed to create event tap (reason: \(reason)).")
|
||||
return
|
||||
}
|
||||
|
||||
eventTapPort = eventTap
|
||||
|
||||
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
||||
self.runLoopSource = runLoopSource
|
||||
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: eventTap, enable: true)
|
||||
isMonitoring = true
|
||||
logger.info("Started monitoring key events via CGEvent tap (reason: \(reason)).")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deactivateTap(reason: String) {
|
||||
guard isMonitoring || eventTapPort != nil else { return }
|
||||
|
||||
if let runLoopSource = runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
|
||||
self.runLoopSource = nil
|
||||
}
|
||||
|
||||
if let eventTapPort = eventTapPort {
|
||||
CGEvent.tapEnable(tap: eventTapPort, enable: false)
|
||||
self.eventTapPort = nil
|
||||
}
|
||||
|
||||
isMonitoring = false
|
||||
clearInputMonitoringProof()
|
||||
logger.info("Suspended key event monitoring (reason: \(reason)).")
|
||||
}
|
||||
|
||||
private func clearInputMonitoringProof() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.inputMonitoringProvenByEvents = false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTapDisabledEvent(_ type: CGEventType) {
|
||||
let reason = type == .tapDisabledByTimeout ? "timeout" : "userInput"
|
||||
logger.error("Event tap disabled by \(reason); scheduling restart.")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshMonitoringState(reason: "tap_disabled_\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processEvent<T>(
|
||||
_ event: T,
|
||||
handlers: () -> [UUID: @Sendable (T) -> Bool]
|
||||
) -> Bool {
|
||||
let handlerList = readState { Array(handlers().values) }
|
||||
return handlerList.reduce(false) { handled, handler in
|
||||
handler(event) || handled
|
||||
}
|
||||
}
|
||||
|
||||
private func processKeyEvent(_ keyEvent: KeyEvent) -> Bool {
|
||||
processEvent(keyEvent, handlers: { continuations })
|
||||
}
|
||||
|
||||
private func processInputEvent(_ inputEvent: InputEvent) -> Bool {
|
||||
processEvent(inputEvent, handlers: { inputContinuations })
|
||||
}
|
||||
|
||||
/// Records that an event was delivered to the tap. Key events are proof that Input
|
||||
/// Monitoring is genuinely granted even when `IOHIDCheckAccess` reports otherwise (#250).
|
||||
fileprivate func noteEventDelivered(type: CGEventType) {
|
||||
guard type == .keyDown || type == .keyUp else { return }
|
||||
let alreadyProven = readState { inputMonitoringProvenByEvents }
|
||||
guard !alreadyProven else { return }
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.inputMonitoringProvenByEvents = true
|
||||
self?.inputMonitoringTrusted = true
|
||||
}
|
||||
if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted {
|
||||
logger.notice("Key events are flowing while IOHIDCheckAccess reports denied; treating Input Monitoring as granted (stale TCC cache, #250).")
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreate the event tap after system transitions that are known to leave taps in a dead or
|
||||
/// stale state (wake from sleep, session reactivation after fast user switching / MDM logout).
|
||||
private func registerSystemEventObservers() {
|
||||
let center = NSWorkspace.shared.notificationCenter
|
||||
let events: [(Notification.Name, String)] = [
|
||||
(NSWorkspace.didWakeNotification, "system_wake"),
|
||||
(NSWorkspace.sessionDidBecomeActiveNotification, "session_active"),
|
||||
]
|
||||
for (name, reason) in events {
|
||||
let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.restartTapAfterSystemEvent(reason: reason)
|
||||
}
|
||||
systemEventObservers.append(observer)
|
||||
}
|
||||
}
|
||||
|
||||
private func restartTapAfterSystemEvent(reason: String) {
|
||||
logger.notice("System transition (\(reason)); recreating event tap to recover from any stale state.")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.deactivateTapOnMain(reason: reason)
|
||||
await self.refreshMonitoringState(reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeyEventMonitorClientLive {
|
||||
private func updateFnStateIfNeeded(type: CGEventType, cgEvent: CGEvent) {
|
||||
guard type == .flagsChanged else { return }
|
||||
let keyCode = Int(cgEvent.getIntegerValueField(.keyboardEventKeycode))
|
||||
guard keyCode == kVK_Function else { return }
|
||||
isFnPressed = cgEvent.flags.contains(.maskSecondaryFn)
|
||||
}
|
||||
|
||||
private func refreshTrustedFlag(promptIfUntrusted: Bool) {
|
||||
var accessibilityTrusted = currentAccessibilityTrust()
|
||||
if !accessibilityTrusted && promptIfUntrusted && !hasPromptedForAccessibilityTrust {
|
||||
accessibilityTrusted = requestAccessibilityTrustPrompt()
|
||||
hasPromptedForAccessibilityTrust = true
|
||||
logger.notice("Prompted for accessibility trust")
|
||||
}
|
||||
|
||||
let inputMonitoringTrusted = currentInputMonitoringTrust()
|
||||
setPermissionFlags(accessibility: accessibilityTrusted, input: inputMonitoringTrusted)
|
||||
}
|
||||
|
||||
private func currentAccessibilityTrust() -> Bool {
|
||||
accessibilityTrustProvider()
|
||||
}
|
||||
|
||||
private func requestAccessibilityTrustPrompt() -> Bool {
|
||||
accessibilityTrustPrompt()
|
||||
}
|
||||
|
||||
private func currentInputMonitoringTrust() -> Bool {
|
||||
if inputMonitoringTrustProvider() {
|
||||
return true
|
||||
}
|
||||
// A stale TCC cache can report denied while key events demonstrably flow (#250);
|
||||
// trust the events over the check so the watchdog and settings UI stay honest.
|
||||
return readState { inputMonitoringProvenByEvents }
|
||||
}
|
||||
|
||||
// Intentionally no request helper: creating the event tap prompts macOS 15+ for Input Monitoring
|
||||
// the same way older versions did, while we still track status for UI.
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum LegacyModelCacheMigrator {
|
||||
static func migrate(
|
||||
from legacyDirectory: URL,
|
||||
to currentDirectory: URL,
|
||||
fileManager: FileManager = .default,
|
||||
isValid: (URL) -> Bool
|
||||
) throws -> Bool {
|
||||
guard legacyDirectory.standardizedFileURL != currentDirectory.standardizedFileURL,
|
||||
fileManager.fileExists(atPath: legacyDirectory.path),
|
||||
!isValid(currentDirectory)
|
||||
else { return false }
|
||||
|
||||
let backupDirectory = currentDirectory
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(currentDirectory.lastPathComponent)-migration-\(UUID().uuidString)")
|
||||
let hadCurrentDirectory = fileManager.fileExists(atPath: currentDirectory.path)
|
||||
|
||||
if hadCurrentDirectory {
|
||||
try fileManager.moveItem(at: currentDirectory, to: backupDirectory)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.moveItem(at: legacyDirectory, to: currentDirectory)
|
||||
guard isValid(currentDirectory) else {
|
||||
throw MigrationError.invalidLegacyModel
|
||||
}
|
||||
if hadCurrentDirectory {
|
||||
try? fileManager.removeItem(at: backupDirectory)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
if fileManager.fileExists(atPath: currentDirectory.path),
|
||||
!fileManager.fileExists(atPath: legacyDirectory.path)
|
||||
{
|
||||
try? fileManager.moveItem(at: currentDirectory, to: legacyDirectory)
|
||||
}
|
||||
if hadCurrentDirectory, fileManager.fileExists(atPath: backupDirectory.path) {
|
||||
try? fileManager.moveItem(at: backupDirectory, to: currentDirectory)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
enum MigrationError: Error {
|
||||
case invalidLegacyModel
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import HexCore
|
||||
|
||||
#if canImport(FluidAudio)
|
||||
import FluidAudio
|
||||
|
||||
actor ParakeetClient {
|
||||
private var asr: AsrManager?
|
||||
private var models: AsrModels?
|
||||
private var currentVariant: ParakeetModel?
|
||||
private let logger = HexLog.parakeet
|
||||
private let vendorDirs = [
|
||||
// Our app-specific cache path convention (under XDG or com.kitlangton.Hex/cache)
|
||||
"fluidaudio/Models",
|
||||
"FluidAudio/Models"
|
||||
]
|
||||
|
||||
func isModelAvailable(_ modelName: String) async -> Bool {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else {
|
||||
logger.error("Unknown Parakeet variant requested: \(modelName)")
|
||||
return false
|
||||
}
|
||||
if currentVariant == variant, asr != nil { return true }
|
||||
|
||||
let directory = AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
||||
migrateLegacyCacheIfNeeded(variant, to: directory)
|
||||
let available = AsrModels.modelsExist(
|
||||
at: directory,
|
||||
version: variant.asrVersion
|
||||
)
|
||||
if available {
|
||||
logger.notice("Found Parakeet cache at \(directory.path)")
|
||||
} else {
|
||||
logger.debug("No Parakeet cache detected variant=\(variant.identifier) path=\(directory.path)")
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else {
|
||||
throw NSError(
|
||||
domain: "Parakeet",
|
||||
code: -4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unsupported Parakeet variant: \(modelName)"]
|
||||
)
|
||||
}
|
||||
if currentVariant == variant, asr != nil { return }
|
||||
if currentVariant != variant {
|
||||
asr = nil
|
||||
models = nil
|
||||
}
|
||||
migrateLegacyCacheIfNeeded(
|
||||
variant,
|
||||
to: AsrModels.defaultCacheDirectory(for: variant.asrVersion)
|
||||
)
|
||||
let t0 = Date()
|
||||
logger.notice("Starting Parakeet load variant=\(variant.identifier)")
|
||||
let p = Progress(totalUnitCount: 100)
|
||||
p.completedUnitCount = 1
|
||||
progress(p)
|
||||
|
||||
// Best-effort progress polling while FluidAudio downloads
|
||||
let fm = FileManager.default
|
||||
let support = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let faDir = support?.appendingPathComponent("FluidAudio/Models/\(variant.identifier)", isDirectory: true)
|
||||
let pollTask = Task {
|
||||
while p.completedUnitCount < 95 {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
if let dir = faDir, let size = directorySize(dir) {
|
||||
let target: Double = 650 * 1024 * 1024 // ~650MB
|
||||
let frac = max(0.0, min(1.0, Double(size) / target))
|
||||
p.completedUnitCount = Int64(5 + frac * 90)
|
||||
progress(p)
|
||||
}
|
||||
if Task.isCancelled { break }
|
||||
}
|
||||
}
|
||||
defer { pollTask.cancel() }
|
||||
|
||||
// Download + load the requested variant (returns when all assets are present)
|
||||
let models = try await AsrModels.downloadAndLoad(version: variant.asrVersion)
|
||||
self.models = models
|
||||
let manager = AsrManager(config: .init(), models: models)
|
||||
self.asr = manager
|
||||
self.currentVariant = variant
|
||||
p.completedUnitCount = 100
|
||||
progress(p)
|
||||
logger.notice("Parakeet ensureLoaded completed in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
||||
}
|
||||
|
||||
private func directorySize(_ dir: URL) -> UInt64? {
|
||||
let fm = FileManager.default
|
||||
guard let en = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: .skipsHiddenFiles) else { return nil }
|
||||
var total: UInt64 = 0
|
||||
for case let url as URL in en {
|
||||
if let vals = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), vals.isRegularFile == true {
|
||||
total &+= UInt64(vals.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func migrateLegacyCacheIfNeeded(_ variant: ParakeetModel, to directory: URL) {
|
||||
let legacyDirectory = directory
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(variant.identifier, isDirectory: true)
|
||||
|
||||
do {
|
||||
if try LegacyModelCacheMigrator.migrate(
|
||||
from: legacyDirectory,
|
||||
to: directory,
|
||||
isValid: {
|
||||
AsrModels.modelsExist(at: $0, version: variant.asrVersion)
|
||||
}
|
||||
) {
|
||||
logger.notice("Migrated legacy Parakeet cache from \(legacyDirectory.path) to \(directory.path)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to migrate legacy Parakeet cache: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func transcribe(_ url: URL) async throws -> String {
|
||||
guard let asr else { throw NSError(domain: "Parakeet", code: -1, userInfo: [NSLocalizedDescriptionKey: "Parakeet not initialized"]) }
|
||||
let t0 = Date()
|
||||
logger.notice("Transcribing with Parakeet file=\(url.lastPathComponent)")
|
||||
var decoderState = TdtDecoderState.make(decoderLayers: await asr.decoderLayerCount)
|
||||
let result = try await asr.transcribe(url, decoderState: &decoderState)
|
||||
logger.info("Parakeet transcription finished in \(String(format: "%.2f", Date().timeIntervalSince(t0)))s")
|
||||
return result.text
|
||||
}
|
||||
|
||||
// Delete cached Parakeet models from known locations and reset state
|
||||
func deleteCaches(modelName: String) async throws {
|
||||
guard let variant = ParakeetModel(rawValue: modelName) else { return }
|
||||
let fm = FileManager.default
|
||||
|
||||
var removedAny = false
|
||||
for dir in modelDirectories(variant) {
|
||||
if fm.fileExists(atPath: dir.path) {
|
||||
try? fm.removeItem(at: dir)
|
||||
removedAny = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset live objects so a future download can proceed cleanly
|
||||
if removedAny {
|
||||
self.asr = nil
|
||||
self.models = nil
|
||||
if currentVariant == variant {
|
||||
currentVariant = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all candidate directories where a Parakeet model might be cached.
|
||||
/// Includes both exact matches and prefixed directories (e.g. versioned folders).
|
||||
private func modelDirectories(_ variant: ParakeetModel) -> [URL] {
|
||||
let fm = FileManager.default
|
||||
var result: [URL] = [AsrModels.defaultCacheDirectory(for: variant.asrVersion)]
|
||||
|
||||
for root in candidateRoots() {
|
||||
for vendor in vendorDirs {
|
||||
let base = root.appendingPathComponent(vendor, isDirectory: true)
|
||||
// Exact match directory
|
||||
let direct = base.appendingPathComponent(variant.identifier, isDirectory: true)
|
||||
result.append(direct)
|
||||
// Prefixed directories (e.g. versioned folders)
|
||||
if let items = try? fm.contentsOfDirectory(at: base, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) {
|
||||
for item in items where item.lastPathComponent.hasPrefix(variant.identifier) && item != direct {
|
||||
result.append(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return result.filter { seen.insert($0.standardizedFileURL.path).inserted }
|
||||
}
|
||||
|
||||
private func candidateRoots() -> [URL] {
|
||||
let fm = FileManager.default
|
||||
let xdg = ProcessInfo.processInfo.environment["XDG_CACHE_HOME"].flatMap { URL(fileURLWithPath: $0, isDirectory: true) }
|
||||
let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
||||
let appCache = try? URL.hexApplicationSupport.appendingPathComponent("cache", isDirectory: true)
|
||||
let userCache = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cache", isDirectory: true)
|
||||
return [xdg, appCache, appSupport, userCache].compactMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private extension ParakeetModel {
|
||||
var asrVersion: AsrModelVersion {
|
||||
switch self {
|
||||
case .englishV2: return .v2
|
||||
case .multilingualV3: return .v3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
actor ParakeetClient {
|
||||
func isModelAvailable(_ modelName: String) async -> Bool { false }
|
||||
func ensureLoaded(modelName: String, progress: @escaping (Progress) -> Void) async throws {
|
||||
throw NSError(
|
||||
domain: "Parakeet",
|
||||
code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Parakeet support not linked. Add Swift Package: https://github.com/FluidInference/FluidAudio.git and link FluidAudio to Hex."]
|
||||
)
|
||||
}
|
||||
func transcribe(_ url: URL) async throws -> String { throw NSError(domain: "Parakeet", code: -3, userInfo: [NSLocalizedDescriptionKey: "Parakeet not available"]) }
|
||||
func deleteCaches(modelName: String) async throws {}
|
||||
}
|
||||
|
||||
#endif
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import HexCore
|
||||
import os.log
|
||||
|
||||
struct ParakeetClipPreparationResult {
|
||||
let url: URL
|
||||
private let cleanupURL: URL?
|
||||
|
||||
init(url: URL, cleanupURL: URL?) {
|
||||
self.url = url
|
||||
self.cleanupURL = cleanupURL
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
guard let cleanupURL else { return }
|
||||
try? FileManager.default.removeItem(at: cleanupURL)
|
||||
}
|
||||
}
|
||||
|
||||
enum ParakeetClipPreparer {
|
||||
private enum Error: LocalizedError {
|
||||
case unsupportedFormat
|
||||
case bufferAllocationFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedFormat:
|
||||
return "Parakeet can only pad mono Float32 PCM recordings."
|
||||
case .bufferAllocationFailed:
|
||||
return "Unable to allocate buffer while preparing Parakeet audio clip."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FluidAudio's LastChunkHandling guidance recommends chunk_duration 1.5s,
|
||||
// so pad to at least that window to avoid decoder errors.
|
||||
static let defaultMinimumDuration: TimeInterval = 1.5
|
||||
|
||||
static func ensureMinimumDuration(
|
||||
url: URL,
|
||||
minimumDuration: TimeInterval = defaultMinimumDuration,
|
||||
logger: os.Logger = HexLog.parakeet
|
||||
) throws -> ParakeetClipPreparationResult {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let format = audioFile.processingFormat
|
||||
let duration = Double(audioFile.length) / format.sampleRate
|
||||
|
||||
logger.debug(
|
||||
"Parakeet clip check file=\(url.lastPathComponent) duration=\(String(format: "%.3f", duration))s sampleRate=\(String(format: "%.0f", format.sampleRate))Hz channels=\(format.channelCount)"
|
||||
)
|
||||
|
||||
guard duration < minimumDuration else {
|
||||
return ParakeetClipPreparationResult(url: url, cleanupURL: nil)
|
||||
}
|
||||
|
||||
guard format.commonFormat == .pcmFormatFloat32 else {
|
||||
throw Error.unsupportedFormat
|
||||
}
|
||||
|
||||
let minimumFrames = AVAudioFrameCount((minimumDuration * format.sampleRate).rounded(.up))
|
||||
let existingFrames64 = max(AVAudioFramePosition(0), audioFile.length)
|
||||
let sourceCapacity = max(AVAudioFrameCount(min(existingFrames64, AVAudioFramePosition(AVAudioFrameCount.max))), 1)
|
||||
|
||||
guard
|
||||
let readBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: sourceCapacity),
|
||||
let paddedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: minimumFrames)
|
||||
else {
|
||||
throw Error.bufferAllocationFailed
|
||||
}
|
||||
|
||||
try audioFile.read(into: readBuffer)
|
||||
let framesRead = min(readBuffer.frameLength, minimumFrames)
|
||||
|
||||
guard
|
||||
let sourceChannels = readBuffer.floatChannelData,
|
||||
let paddedChannels = paddedBuffer.floatChannelData
|
||||
else {
|
||||
throw Error.unsupportedFormat
|
||||
}
|
||||
|
||||
let channelCount = Int(format.channelCount)
|
||||
for channel in 0..<channelCount {
|
||||
let dest = paddedChannels[channel]
|
||||
let src = sourceChannels[channel]
|
||||
if framesRead > 0 {
|
||||
dest.update(from: src, count: Int(framesRead))
|
||||
}
|
||||
let padCount = Int(minimumFrames - framesRead)
|
||||
if padCount > 0 {
|
||||
dest.advanced(by: Int(framesRead)).initialize(repeating: 0, count: padCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
paddedBuffer.frameLength = minimumFrames
|
||||
|
||||
let paddedURL = makePaddedURL(from: url)
|
||||
if FileManager.default.fileExists(atPath: paddedURL.path) {
|
||||
try FileManager.default.removeItem(at: paddedURL)
|
||||
}
|
||||
|
||||
let paddedFile = try AVAudioFile(forWriting: paddedURL, settings: audioFile.fileFormat.settings)
|
||||
try paddedFile.write(from: paddedBuffer)
|
||||
|
||||
logger.notice(
|
||||
"Padded clip for Parakeet file=\(url.lastPathComponent) original=\(String(format: "%.3f", duration))s paddedTo=\(String(format: "%.3f", minimumDuration))s output=\(paddedURL.lastPathComponent)"
|
||||
)
|
||||
|
||||
return ParakeetClipPreparationResult(url: paddedURL, cleanupURL: paddedURL)
|
||||
}
|
||||
|
||||
private static func makePaddedURL(from url: URL) -> URL {
|
||||
let base = url.deletingLastPathComponent()
|
||||
let stem = url.deletingPathExtension().lastPathComponent
|
||||
return base.appendingPathComponent("\(stem)-parakeet-padded.wav")
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// PasteboardClient.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Sauce
|
||||
import SwiftUI
|
||||
|
||||
private let pasteboardLogger = HexLog.pasteboard
|
||||
|
||||
@DependencyClient
|
||||
struct PasteboardClient {
|
||||
var paste: @Sendable (String) async -> Void
|
||||
var copy: @Sendable (String) async -> Void
|
||||
var sendKeyboardCommand: @Sendable (KeyboardCommand) async -> Void
|
||||
}
|
||||
|
||||
extension PasteboardClient: DependencyKey {
|
||||
static var liveValue: Self {
|
||||
let live = PasteboardClientLive()
|
||||
return .init(
|
||||
paste: { text in
|
||||
await live.paste(text: text)
|
||||
},
|
||||
copy: { text in
|
||||
await live.copy(text: text)
|
||||
},
|
||||
sendKeyboardCommand: { command in
|
||||
await live.sendKeyboardCommand(command)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var pasteboard: PasteboardClient {
|
||||
get { self[PasteboardClient.self] }
|
||||
set { self[PasteboardClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct PasteboardClientLive {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
private struct PasteboardSnapshot {
|
||||
let items: [[String: Any]]
|
||||
|
||||
init(pasteboard: NSPasteboard) {
|
||||
var saved: [[String: Any]] = []
|
||||
for item in pasteboard.pasteboardItems ?? [] {
|
||||
var itemDict: [String: Any] = [:]
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
itemDict[type.rawValue] = data
|
||||
}
|
||||
}
|
||||
saved.append(itemDict)
|
||||
}
|
||||
self.items = saved
|
||||
}
|
||||
|
||||
func restore(to pasteboard: NSPasteboard) {
|
||||
pasteboard.clearContents()
|
||||
for itemDict in items {
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in itemDict {
|
||||
if let data = data as? Data {
|
||||
item.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type))
|
||||
}
|
||||
}
|
||||
pasteboard.writeObjects([item])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func paste(text: String) async {
|
||||
if hexSettings.useClipboardPaste {
|
||||
await pasteWithClipboard(text)
|
||||
} else {
|
||||
simulateTypingWithAppleScript(text)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func copy(text: String) async {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendKeyboardCommand(_ command: KeyboardCommand) async {
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
|
||||
// Convert modifiers to CGEventFlags and key codes for modifier keys
|
||||
var modifierKeyCodes: [CGKeyCode] = []
|
||||
var flags = CGEventFlags()
|
||||
|
||||
for modifier in command.modifiers.sorted {
|
||||
switch modifier.kind {
|
||||
case .command:
|
||||
flags.insert(.maskCommand)
|
||||
modifierKeyCodes.append(55) // Left Cmd
|
||||
case .shift:
|
||||
flags.insert(.maskShift)
|
||||
modifierKeyCodes.append(56) // Left Shift
|
||||
case .option:
|
||||
flags.insert(.maskAlternate)
|
||||
modifierKeyCodes.append(58) // Left Option
|
||||
case .control:
|
||||
flags.insert(.maskControl)
|
||||
modifierKeyCodes.append(59) // Left Control
|
||||
case .fn:
|
||||
flags.insert(.maskSecondaryFn)
|
||||
// Fn key doesn't need explicit key down/up
|
||||
}
|
||||
}
|
||||
|
||||
// Press modifiers down
|
||||
for keyCode in modifierKeyCodes {
|
||||
let modDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
|
||||
modDown?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
// Press main key if present
|
||||
if let key = command.key {
|
||||
let keyCode = Sauce.shared.keyCode(for: key)
|
||||
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
|
||||
keyDown?.flags = flags
|
||||
keyDown?.post(tap: .cghidEventTap)
|
||||
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
keyUp?.flags = flags
|
||||
keyUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
// Release modifiers in reverse order
|
||||
for keyCode in modifierKeyCodes.reversed() {
|
||||
let modUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
modUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
pasteboardLogger.debug("Sent keyboard command: \(command.displayName)")
|
||||
}
|
||||
|
||||
/// Pastes current clipboard content to the frontmost application
|
||||
static func pasteToFrontmostApp() -> Bool {
|
||||
let script = """
|
||||
if application "System Events" is not running then
|
||||
tell application "System Events" to launch
|
||||
delay 0.1
|
||||
end if
|
||||
tell application "System Events"
|
||||
tell process (name of first application process whose frontmost is true)
|
||||
tell (menu item "Paste" of menu of menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
|
||||
if exists then
|
||||
log (get properties of it)
|
||||
if enabled then
|
||||
click it
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end if
|
||||
end if
|
||||
end tell
|
||||
tell (menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
|
||||
if exists then
|
||||
if enabled then
|
||||
click it
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end if
|
||||
else
|
||||
return false
|
||||
end if
|
||||
end tell
|
||||
end tell
|
||||
end tell
|
||||
"""
|
||||
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: script) {
|
||||
let result = scriptObject.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
pasteboardLogger.error("AppleScript paste failed: \(error)")
|
||||
return false
|
||||
}
|
||||
return result.booleanValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func pasteWithClipboard(_ text: String) async {
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = PasteboardSnapshot(pasteboard: pasteboard)
|
||||
let targetChangeCount = writeAndTrackChangeCount(pasteboard: pasteboard, text: text)
|
||||
_ = await waitForPasteboardCommit(targetChangeCount: targetChangeCount)
|
||||
let pasteSucceeded = await performPaste(text)
|
||||
|
||||
// Only restore original pasteboard contents if:
|
||||
// 1. Copying to clipboard is disabled AND
|
||||
// 2. The paste operation succeeded
|
||||
if !hexSettings.copyToClipboard && pasteSucceeded {
|
||||
let savedSnapshot = snapshot
|
||||
Task { @MainActor in
|
||||
// Give slower apps a short window to read the plain-text entry
|
||||
// before we repopulate the clipboard with the user's previous rich data.
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
pasteboard.clearContents()
|
||||
savedSnapshot.restore(to: pasteboard)
|
||||
}
|
||||
}
|
||||
|
||||
// If we failed to paste AND user doesn't want clipboard retention,
|
||||
// show a notification that text is available in clipboard
|
||||
if !pasteSucceeded && !hexSettings.copyToClipboard {
|
||||
// Keep the transcribed text in clipboard regardless of setting
|
||||
pasteboardLogger.notice("Paste operation failed; text remains in clipboard as fallback.")
|
||||
|
||||
// TODO: Could add a notification here to inform user
|
||||
// that text is available in clipboard
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func writeAndTrackChangeCount(pasteboard: NSPasteboard, text: String) -> Int {
|
||||
let before = pasteboard.changeCount
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
let after = pasteboard.changeCount
|
||||
if after == before {
|
||||
// Ensure we always advance by at least one to avoid infinite waits if the system
|
||||
// coalesces writes (seen on Sonoma betas with zero-length strings).
|
||||
return after + 1
|
||||
}
|
||||
return after
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForPasteboardCommit(
|
||||
targetChangeCount: Int,
|
||||
timeout: Duration = .milliseconds(150),
|
||||
pollInterval: Duration = .milliseconds(5)
|
||||
) async -> Bool {
|
||||
guard targetChangeCount > NSPasteboard.general.changeCount else { return true }
|
||||
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while ContinuousClock.now < deadline {
|
||||
if NSPasteboard.general.changeCount >= targetChangeCount {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(for: pollInterval)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Paste Orchestration
|
||||
|
||||
@MainActor
|
||||
private enum PasteStrategy: CaseIterable {
|
||||
case cmdV
|
||||
case menuItem
|
||||
case accessibility
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performPaste(_ text: String) async -> Bool {
|
||||
for strategy in PasteStrategy.allCases {
|
||||
if await attemptPaste(text, using: strategy) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func attemptPaste(_ text: String, using strategy: PasteStrategy) async -> Bool {
|
||||
switch strategy {
|
||||
case .cmdV:
|
||||
return await postCmdV(delayMs: 0)
|
||||
case .menuItem:
|
||||
return PasteboardClientLive.pasteToFrontmostApp()
|
||||
case .accessibility:
|
||||
return (try? Self.insertTextAtCursor(text)) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func postCmdV(delayMs: Int) async -> Bool {
|
||||
// Optional tiny wait before keystrokes
|
||||
try? await wait(milliseconds: delayMs)
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
let vKey = vKeyCode()
|
||||
let cmdKey: CGKeyCode = 55
|
||||
let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: true)
|
||||
let vDown = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true)
|
||||
vDown?.flags = .maskCommand
|
||||
let vUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
|
||||
vUp?.flags = .maskCommand
|
||||
let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: false)
|
||||
cmdDown?.post(tap: .cghidEventTap)
|
||||
vDown?.post(tap: .cghidEventTap)
|
||||
vUp?.post(tap: .cghidEventTap)
|
||||
cmdUp?.post(tap: .cghidEventTap)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func vKeyCode() -> CGKeyCode {
|
||||
if Thread.isMainThread { return Sauce.shared.keyCode(for: .v) }
|
||||
return DispatchQueue.main.sync { Sauce.shared.keyCode(for: .v) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func wait(milliseconds: Int) async throws {
|
||||
try Task.checkCancellation()
|
||||
try await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000)
|
||||
}
|
||||
|
||||
func simulateTypingWithAppleScript(_ text: String) {
|
||||
let escapedText = text.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
let script = NSAppleScript(source: "tell application \"System Events\" to keystroke \"\(escapedText)\"")
|
||||
var error: NSDictionary?
|
||||
script?.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
pasteboardLogger.error("Error executing AppleScript typing fallback: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
enum PasteError: Error {
|
||||
case systemWideElementCreationFailed
|
||||
case focusedElementNotFound
|
||||
case elementDoesNotSupportTextEditing
|
||||
case failedToInsertText
|
||||
}
|
||||
|
||||
static func insertTextAtCursor(_ text: String) throws {
|
||||
// Get the system-wide accessibility element
|
||||
let systemWideElement = AXUIElementCreateSystemWide()
|
||||
|
||||
// Get the focused element
|
||||
var focusedElementRef: CFTypeRef?
|
||||
let axError = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedElementRef)
|
||||
|
||||
guard axError == .success, let focusedElementRef = focusedElementRef else {
|
||||
throw PasteError.focusedElementNotFound
|
||||
}
|
||||
|
||||
let focusedElement = focusedElementRef as! AXUIElement
|
||||
|
||||
// Verify if the focused element supports text insertion
|
||||
var value: CFTypeRef?
|
||||
let supportsText = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute as CFString, &value) == .success
|
||||
let supportsSelectedText = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, &value) == .success
|
||||
|
||||
if !supportsText && !supportsSelectedText {
|
||||
throw PasteError.elementDoesNotSupportTextEditing
|
||||
}
|
||||
|
||||
// Insert text at cursor position by replacing selected text (or empty selection)
|
||||
let insertResult = AXUIElementSetAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, text as CFTypeRef)
|
||||
|
||||
if insertResult != .success {
|
||||
throw PasteError.failedToInsertText
|
||||
}
|
||||
}
|
||||
}
|
||||
+1701
File diff suppressed because it is too large
Load Diff
Executable
+196
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// SoundEffect.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/26/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
// Thank you. Never mind then.What a beautiful idea.
|
||||
public enum SoundEffect: String, CaseIterable {
|
||||
case pasteTranscript
|
||||
case startRecording
|
||||
case stopRecording
|
||||
case cancel
|
||||
|
||||
public var fileName: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var fileExtension: String {
|
||||
"mp3"
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct SoundEffectsClient {
|
||||
public var play: @Sendable (SoundEffect) -> Void
|
||||
public var stop: @Sendable (SoundEffect) -> Void
|
||||
public var stopAll: @Sendable () -> Void
|
||||
public var preloadSounds: @Sendable () async -> Void
|
||||
public var setEnabled: @Sendable (Bool) async -> Void
|
||||
}
|
||||
|
||||
extension SoundEffectsClient: DependencyKey {
|
||||
public static var liveValue: SoundEffectsClient {
|
||||
let live = SoundEffectsClientLive()
|
||||
return SoundEffectsClient(
|
||||
play: { soundEffect in
|
||||
Task { await live.play(soundEffect) }
|
||||
},
|
||||
stop: { soundEffect in
|
||||
Task { await live.stop(soundEffect) }
|
||||
},
|
||||
stopAll: {
|
||||
Task { await live.stopAll() }
|
||||
},
|
||||
preloadSounds: {
|
||||
await live.preloadSounds()
|
||||
},
|
||||
setEnabled: { enabled in
|
||||
await live.setEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public extension DependencyValues {
|
||||
var soundEffects: SoundEffectsClient {
|
||||
get { self[SoundEffectsClient.self] }
|
||||
set { self[SoundEffectsClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
actor SoundEffectsClientLive {
|
||||
private let logger = HexLog.sound
|
||||
private let baselineVolume = HexSettings.baseSoundEffectsVolume
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
private var playerNodes: [SoundEffect: AVAudioPlayerNode] = [:]
|
||||
private var audioBuffers: [SoundEffect: AVAudioPCMBuffer] = [:]
|
||||
private var idleShutdownTask: Task<Void, Never>?
|
||||
/// Comfortably longer than any sound effect, short enough that an idle Hex doesn't keep
|
||||
/// an output IOProc (and coreaudiod) running around the clock (#209).
|
||||
private static let idleShutdownDelay: Duration = .seconds(10)
|
||||
|
||||
func play(_ soundEffect: SoundEffect) {
|
||||
guard hexSettings.soundEffectsEnabled else { return }
|
||||
guard let player = playerNodes[soundEffect], let buffer = audioBuffers[soundEffect] else {
|
||||
logger.error("Requested sound \(soundEffect.rawValue) not preloaded")
|
||||
return
|
||||
}
|
||||
prepareEngineIfNeeded()
|
||||
let clampedVolume = min(max(hexSettings.soundEffectsVolume, 0), baselineVolume)
|
||||
player.volume = Float(clampedVolume)
|
||||
player.stop()
|
||||
player.scheduleBuffer(buffer, at: nil, options: [], completionHandler: nil)
|
||||
player.play()
|
||||
scheduleIdleShutdown()
|
||||
}
|
||||
|
||||
/// Stops the output engine shortly after playback so it doesn't run while idle.
|
||||
/// Restarting it on the next play costs only a few milliseconds.
|
||||
private func scheduleIdleShutdown() {
|
||||
idleShutdownTask?.cancel()
|
||||
idleShutdownTask = Task {
|
||||
try? await Task.sleep(for: Self.idleShutdownDelay)
|
||||
guard !Task.isCancelled else { return }
|
||||
stopEngineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func stop(_ soundEffect: SoundEffect) {
|
||||
playerNodes[soundEffect]?.stop()
|
||||
}
|
||||
|
||||
func stopAll() {
|
||||
playerNodes.values.forEach { $0.stop() }
|
||||
}
|
||||
|
||||
func preloadSounds() async {
|
||||
guard !isSetup else { return }
|
||||
|
||||
for soundEffect in SoundEffect.allCases {
|
||||
loadSound(soundEffect)
|
||||
}
|
||||
|
||||
isSetup = true
|
||||
}
|
||||
|
||||
func setEnabled(_: Bool) async {
|
||||
await preloadSounds()
|
||||
|
||||
// No prewarm on enable: play() starts the engine lazily, and an idle prewarm would
|
||||
// just be shut down again by the idle timer.
|
||||
if !hexSettings.soundEffectsEnabled {
|
||||
stopAll()
|
||||
idleShutdownTask?.cancel()
|
||||
stopEngineIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var isSetup = false
|
||||
|
||||
private func loadSound(_ soundEffect: SoundEffect) {
|
||||
guard let url = Bundle.main.url(
|
||||
forResource: soundEffect.fileName,
|
||||
withExtension: soundEffect.fileExtension
|
||||
) else {
|
||||
logger.error("Missing sound resource \(soundEffect.fileName).\(soundEffect.fileExtension)")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let frameCount = AVAudioFrameCount(file.length)
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else {
|
||||
logger.error("Failed to allocate buffer for \(soundEffect.rawValue)")
|
||||
return
|
||||
}
|
||||
try file.read(into: buffer)
|
||||
audioBuffers[soundEffect] = buffer
|
||||
|
||||
let player = AVAudioPlayerNode()
|
||||
engine.attach(player)
|
||||
engine.connect(player, to: engine.mainMixerNode, format: buffer.format)
|
||||
playerNodes[soundEffect] = player
|
||||
} catch {
|
||||
logger.error("Failed to load sound \(soundEffect.rawValue): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareEngineIfNeeded() {
|
||||
guard !engine.isRunning else { return }
|
||||
engine.prepare()
|
||||
if #available(macOS 13.0, *) {
|
||||
engine.isAutoShutdownEnabled = false
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
logger.error("Failed to start AVAudioEngine: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEngineIfNeeded() {
|
||||
guard engine.isRunning else { return }
|
||||
engine.stop()
|
||||
logger.debug("Sound effects engine stopped")
|
||||
}
|
||||
|
||||
deinit {
|
||||
playerNodes.values.forEach {
|
||||
$0.stop()
|
||||
engine.detach($0)
|
||||
}
|
||||
engine.stop()
|
||||
}
|
||||
}
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import HexCore
|
||||
|
||||
private final class FloatRingBuffer {
|
||||
private let lock = NSLock()
|
||||
private var buffer: [Float]
|
||||
private var writeIndex = 0
|
||||
private var validSampleCount = 0
|
||||
|
||||
init(capacity: Int) {
|
||||
buffer = Array(repeating: 0, count: max(1, capacity))
|
||||
}
|
||||
|
||||
func append(_ samples: UnsafeBufferPointer<Float>) {
|
||||
guard !samples.isEmpty else { return }
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
for sample in samples {
|
||||
buffer[writeIndex] = sample
|
||||
writeIndex = (writeIndex + 1) % buffer.count
|
||||
}
|
||||
|
||||
validSampleCount = min(buffer.count, validSampleCount + samples.count)
|
||||
}
|
||||
|
||||
func recentSamples(count requestedCount: Int) -> [Float] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let sampleCount = min(max(0, requestedCount), validSampleCount)
|
||||
guard sampleCount > 0 else { return [] }
|
||||
|
||||
let startIndex = (writeIndex - sampleCount + buffer.count) % buffer.count
|
||||
if startIndex + sampleCount <= buffer.count {
|
||||
return Array(buffer[startIndex ..< startIndex + sampleCount])
|
||||
}
|
||||
|
||||
let firstChunk = Array(buffer[startIndex ..< buffer.count])
|
||||
let secondChunk = Array(buffer[0 ..< (sampleCount - firstChunk.count)])
|
||||
return firstChunk + secondChunk
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
writeIndex = 0
|
||||
validSampleCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private struct SuperFastCaptureConstants {
|
||||
static let sampleRate: Double = 16_000
|
||||
static let ringBufferDuration: TimeInterval = 1.0
|
||||
static let defaultPreRollDuration: TimeInterval = 0.45
|
||||
static let tapBufferSize: AVAudioFrameCount = 2_048
|
||||
static let fallbackStopGracePeriod: TimeInterval = 0.05
|
||||
static let minimumStopGracePeriod: TimeInterval = 0.02
|
||||
static let maximumStopGracePeriod: TimeInterval = 0.08
|
||||
static let stopGraceSafetyMargin: TimeInterval = 0.008
|
||||
static let callbackTimingWindowSize = 8
|
||||
}
|
||||
|
||||
enum CaptureRecordingMode: String {
|
||||
case standard = "standard"
|
||||
case superFast = "super-fast"
|
||||
|
||||
var preRollDuration: TimeInterval {
|
||||
switch self {
|
||||
case .standard:
|
||||
0
|
||||
case .superFast:
|
||||
SuperFastCaptureConstants.defaultPreRollDuration
|
||||
}
|
||||
}
|
||||
|
||||
var keepsWarmBuffer: Bool {
|
||||
self == .superFast
|
||||
}
|
||||
}
|
||||
|
||||
final class SuperFastCaptureController {
|
||||
enum FinishRecordingResult {
|
||||
case captured(URL)
|
||||
case failed(RecordingFailure)
|
||||
case idle
|
||||
}
|
||||
|
||||
struct StopTimingEstimate {
|
||||
let gracePeriod: TimeInterval
|
||||
let callbackInterval: TimeInterval
|
||||
let bufferDuration: TimeInterval
|
||||
}
|
||||
|
||||
private struct ActiveRecording {
|
||||
let url: URL
|
||||
let file: AVAudioFile
|
||||
let requestedAt: Date
|
||||
let prependedDuration: TimeInterval
|
||||
var didLogFirstBuffer: Bool
|
||||
}
|
||||
|
||||
private let logger = HexLog.recording
|
||||
private let processingQueue = DispatchQueue(label: "com.kitlangton.Hex.SuperFastCapture")
|
||||
private let meterContinuation: AsyncStream<Meter>.Continuation
|
||||
private let ringBuffer = FloatRingBuffer(
|
||||
capacity: Int(SuperFastCaptureConstants.sampleRate * SuperFastCaptureConstants.ringBufferDuration)
|
||||
)
|
||||
private let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: SuperFastCaptureConstants.sampleRate,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
|
||||
private var engine: AVAudioEngine?
|
||||
private var converter: AVAudioConverter?
|
||||
private var configurationChangeObserver: NSObjectProtocol?
|
||||
private var activeRecording: ActiveRecording?
|
||||
private var captureGeneration = 0
|
||||
private var recordingFailure: RecordingFailure?
|
||||
private var keepWarmBuffer = false
|
||||
private var lastProcessedBufferAt: Date?
|
||||
private var recentCallbackIntervals: [TimeInterval] = []
|
||||
private var recentBufferDurations: [TimeInterval] = []
|
||||
private let onEngineConfigurationChange: @Sendable (Int) -> Void
|
||||
|
||||
init(
|
||||
meterContinuation: AsyncStream<Meter>.Continuation,
|
||||
onEngineConfigurationChange: @escaping @Sendable (Int) -> Void
|
||||
) {
|
||||
self.meterContinuation = meterContinuation
|
||||
self.onEngineConfigurationChange = onEngineConfigurationChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
||||
var isRunning: Bool {
|
||||
engine?.isRunning == true
|
||||
}
|
||||
|
||||
var isRecording: Bool {
|
||||
processingQueue.sync { activeRecording != nil }
|
||||
}
|
||||
|
||||
var stopTimingEstimate: StopTimingEstimate {
|
||||
processingQueue.sync {
|
||||
let callbackInterval = recentCallbackIntervals.max() ?? 0
|
||||
let bufferDuration = recentBufferDurations.max() ?? 0
|
||||
let observedCadence = max(callbackInterval, bufferDuration)
|
||||
let gracePeriod = min(
|
||||
max(
|
||||
observedCadence > 0
|
||||
? observedCadence + SuperFastCaptureConstants.stopGraceSafetyMargin
|
||||
: SuperFastCaptureConstants.fallbackStopGracePeriod,
|
||||
SuperFastCaptureConstants.minimumStopGracePeriod
|
||||
),
|
||||
SuperFastCaptureConstants.maximumStopGracePeriod
|
||||
)
|
||||
return StopTimingEstimate(
|
||||
gracePeriod: gracePeriod,
|
||||
callbackInterval: callbackInterval,
|
||||
bufferDuration: bufferDuration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func startIfNeeded(reason: String = "unknown", keepWarmBuffer: Bool = false) throws {
|
||||
processingQueue.sync {
|
||||
let didDisableWarmBuffer = self.keepWarmBuffer && !keepWarmBuffer
|
||||
self.keepWarmBuffer = keepWarmBuffer
|
||||
if didDisableWarmBuffer, activeRecording == nil {
|
||||
ringBuffer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
if engine?.isRunning == true {
|
||||
logger.debug("Capture engine already armed reason=\(reason)")
|
||||
return
|
||||
}
|
||||
|
||||
stop(reason: "restart-before-arm")
|
||||
try armEngine(reason: reason)
|
||||
}
|
||||
|
||||
/// Tears down and recreates the engine while keeping the active recording file open, so
|
||||
/// capture resumes onto the same file after a device/route change mid-recording
|
||||
/// (#251, #252, #218, #226). The ring buffer, timing metrics, and active recording survive;
|
||||
/// only the engine, tap, and converter are rebuilt.
|
||||
func restartPreservingRecording(reason: String) throws {
|
||||
logger.notice("Restarting capture engine preserving active recording reason=\(reason)")
|
||||
detachEngine()
|
||||
try armEngine(reason: reason)
|
||||
}
|
||||
|
||||
private func armEngine(reason: String) throws {
|
||||
let engine = AVAudioEngine()
|
||||
let inputNode = engine.inputNode
|
||||
let inputFormat = inputNode.inputFormat(forBus: 0)
|
||||
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
||||
throw NSError(
|
||||
domain: "SuperFastCapture",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to create the capture engine audio converter."]
|
||||
)
|
||||
}
|
||||
if inputFormat.channelCount > 1 {
|
||||
converter.channelMap = [NSNumber(value: 0)]
|
||||
}
|
||||
|
||||
let generation = processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
self.converter = converter
|
||||
recordingFailure = nil
|
||||
return captureGeneration
|
||||
}
|
||||
|
||||
inputNode.installTap(onBus: 0, bufferSize: SuperFastCaptureConstants.tapBufferSize, format: inputFormat) {
|
||||
[weak self] buffer, _ in
|
||||
self?.enqueue(buffer, generation: generation)
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
self.converter = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
self.engine = engine
|
||||
configurationChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: engine,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.handleConfigurationChange(generation: generation)
|
||||
}
|
||||
logger.notice(
|
||||
"Capture engine armed reason=\(reason) sampleRate=\(String(format: "%.0f", inputFormat.sampleRate))Hz channels=\(inputFormat.channelCount) ringBuffer=\(String(format: "%.2f", SuperFastCaptureConstants.ringBufferDuration))s defaultPreRoll=\(String(format: "%.2f", SuperFastCaptureConstants.defaultPreRollDuration))s"
|
||||
)
|
||||
}
|
||||
|
||||
func stop(reason: String = "unknown") {
|
||||
if engine != nil {
|
||||
logger.notice("Capture engine stopped reason=\(reason)")
|
||||
}
|
||||
detachEngine(clearingRecordingState: true)
|
||||
}
|
||||
|
||||
/// Removes the tap, observer, converter, and engine. Bumps the capture generation so
|
||||
/// in-flight tap callbacks from the old engine are ignored. Recording state (active file,
|
||||
/// ring buffer, timing metrics) is preserved unless `clearingRecordingState` is set, which
|
||||
/// is what lets restartPreservingRecording resume capture onto the same file.
|
||||
private func detachEngine(clearingRecordingState: Bool = false) {
|
||||
if let inputNode = engine?.inputNode {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
if let configurationChangeObserver {
|
||||
NotificationCenter.default.removeObserver(configurationChangeObserver)
|
||||
self.configurationChangeObserver = nil
|
||||
}
|
||||
processingQueue.sync {
|
||||
captureGeneration += 1
|
||||
converter = nil
|
||||
if clearingRecordingState {
|
||||
activeRecording = nil
|
||||
recordingFailure = nil
|
||||
ringBuffer.clear()
|
||||
lastProcessedBufferAt = nil
|
||||
recentCallbackIntervals.removeAll(keepingCapacity: false)
|
||||
recentBufferDurations.removeAll(keepingCapacity: false)
|
||||
}
|
||||
}
|
||||
engine?.stop()
|
||||
engine = nil
|
||||
}
|
||||
|
||||
private func handleConfigurationChange(generation: Int) {
|
||||
guard processingQueue.sync(execute: { Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) }) else {
|
||||
return
|
||||
}
|
||||
logger.notice("Capture engine configuration changed")
|
||||
onEngineConfigurationChange(generation)
|
||||
}
|
||||
|
||||
static func shouldProcessCallback(callbackGeneration: Int, currentGeneration: Int) -> Bool {
|
||||
callbackGeneration == currentGeneration
|
||||
}
|
||||
|
||||
func isCurrentGeneration(_ generation: Int) -> Bool {
|
||||
processingQueue.sync { generation == captureGeneration }
|
||||
}
|
||||
|
||||
func beginRecording(to url: URL, requestedAt: Date = Date(), mode: CaptureRecordingMode) throws {
|
||||
try startIfNeeded(reason: "begin-recording", keepWarmBuffer: mode.keepsWarmBuffer)
|
||||
|
||||
var startError: Error?
|
||||
processingQueue.sync {
|
||||
do {
|
||||
recordingFailure = nil
|
||||
let file = try AVAudioFile(
|
||||
forWriting: url,
|
||||
settings: [
|
||||
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
||||
AVSampleRateKey: SuperFastCaptureConstants.sampleRate,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVLinearPCMBitDepthKey: 32,
|
||||
AVLinearPCMIsFloatKey: true,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
AVLinearPCMIsNonInterleaved: true,
|
||||
],
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
interleaved: false
|
||||
)
|
||||
|
||||
let preRollDuration = mode.preRollDuration
|
||||
let preRollFrameCount = Int(preRollDuration * SuperFastCaptureConstants.sampleRate)
|
||||
let preRollSamples = ringBuffer.recentSamples(count: preRollFrameCount)
|
||||
let prependedDuration = Double(preRollSamples.count) / SuperFastCaptureConstants.sampleRate
|
||||
if !preRollSamples.isEmpty {
|
||||
try write(samples: preRollSamples, to: file)
|
||||
}
|
||||
|
||||
logger.notice(
|
||||
"Capture engine recording file opened prepended=\(String(format: "%.3f", prependedDuration))s requestedPreRoll=\(String(format: "%.3f", preRollDuration))s"
|
||||
)
|
||||
activeRecording = ActiveRecording(
|
||||
url: url,
|
||||
file: file,
|
||||
requestedAt: requestedAt,
|
||||
prependedDuration: prependedDuration,
|
||||
didLogFirstBuffer: false
|
||||
)
|
||||
} catch {
|
||||
startError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let startError {
|
||||
throw startError
|
||||
}
|
||||
}
|
||||
|
||||
func finishRecording(clearBuffer: Bool = true) -> FinishRecordingResult {
|
||||
processingQueue.sync {
|
||||
let result: FinishRecordingResult
|
||||
if let recordingFailure {
|
||||
result = .failed(recordingFailure)
|
||||
} else if let url = activeRecording?.url {
|
||||
result = .captured(url)
|
||||
} else {
|
||||
result = .idle
|
||||
}
|
||||
activeRecording = nil
|
||||
recordingFailure = nil
|
||||
if clearBuffer {
|
||||
ringBuffer.clear()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
||||
guard let copy = clone(buffer) else { return }
|
||||
processingQueue.async { [weak self] in
|
||||
self?.process(copy, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func process(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
||||
guard Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) else {
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
if let lastProcessedBufferAt {
|
||||
appendRecentMetric(now.timeIntervalSince(lastProcessedBufferAt), to: &recentCallbackIntervals)
|
||||
}
|
||||
lastProcessedBufferAt = now
|
||||
appendRecentMetric(Double(buffer.frameLength) / buffer.format.sampleRate, to: &recentBufferDurations)
|
||||
|
||||
guard let converted = convert(buffer),
|
||||
converted.frameLength > 0,
|
||||
let samples = converted.floatChannelData?[0]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let sampleCount = Int(converted.frameLength)
|
||||
if keepWarmBuffer, activeRecording == nil {
|
||||
ringBuffer.append(UnsafeBufferPointer(start: samples, count: sampleCount))
|
||||
}
|
||||
|
||||
if activeRecording != nil {
|
||||
meterContinuation.yield(meter(for: samples, count: sampleCount))
|
||||
}
|
||||
|
||||
guard var recording = activeRecording else { return }
|
||||
if !recording.didLogFirstBuffer {
|
||||
let timeToFirstBuffer = Date().timeIntervalSince(recording.requestedAt)
|
||||
logger.notice(
|
||||
"Capture engine first buffer latency=\(String(format: "%.3f", timeToFirstBuffer))s prepended=\(String(format: "%.3f", recording.prependedDuration))s frames=\(sampleCount)"
|
||||
)
|
||||
recording.didLogFirstBuffer = true
|
||||
activeRecording = recording
|
||||
}
|
||||
|
||||
do {
|
||||
try recording.file.write(from: converted)
|
||||
} catch {
|
||||
logger.error("Failed to write capture engine audio: \(error.localizedDescription)")
|
||||
activeRecording = nil
|
||||
recordingFailure = .captureWriteFailed(error.localizedDescription)
|
||||
FileManager.default.removeItemIfExists(at: recording.url)
|
||||
}
|
||||
}
|
||||
|
||||
private func convert(_ inputBuffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
guard let converter else { return nil }
|
||||
|
||||
let sampleRateRatio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||
let frameCapacity = AVAudioFrameCount(
|
||||
max(1, (Double(inputBuffer.frameLength) * sampleRateRatio).rounded(.up) + 32)
|
||||
)
|
||||
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCapacity) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var error: NSError?
|
||||
var consumedInput = false
|
||||
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
|
||||
if consumedInput {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
|
||||
consumedInput = true
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
if let error {
|
||||
logger.error("Failed to convert capture engine audio: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .haveData, .inputRanDry, .endOfStream:
|
||||
return outputBuffer.frameLength > 0 ? outputBuffer : nil
|
||||
case .error:
|
||||
return nil
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func write(samples: [Float], to file: AVAudioFile) throws {
|
||||
guard !samples.isEmpty,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(samples.count)),
|
||||
let channelData = buffer.floatChannelData?[0]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
buffer.frameLength = AVAudioFrameCount(samples.count)
|
||||
samples.withUnsafeBufferPointer { sampleBuffer in
|
||||
guard let baseAddress = sampleBuffer.baseAddress else { return }
|
||||
channelData.update(from: baseAddress, count: sampleBuffer.count)
|
||||
}
|
||||
try file.write(from: buffer)
|
||||
}
|
||||
|
||||
private func meter(for samples: UnsafePointer<Float>, count: Int) -> Meter {
|
||||
guard count > 0 else {
|
||||
return Meter(averagePower: 0, peakPower: 0)
|
||||
}
|
||||
|
||||
var sumOfSquares: Float = 0
|
||||
var peak: Float = 0
|
||||
for index in 0 ..< count {
|
||||
let sample = samples[index]
|
||||
let magnitude = abs(sample)
|
||||
sumOfSquares += sample * sample
|
||||
peak = max(peak, magnitude)
|
||||
}
|
||||
|
||||
let rms = sqrt(sumOfSquares / Float(count))
|
||||
return Meter(averagePower: Double(rms), peakPower: Double(peak))
|
||||
}
|
||||
|
||||
private func clone(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
guard let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameLength) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
copy.frameLength = buffer.frameLength
|
||||
|
||||
let sourceBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList)
|
||||
let destinationBuffers = UnsafeMutableAudioBufferListPointer(copy.mutableAudioBufferList)
|
||||
for index in sourceBuffers.indices {
|
||||
let source = sourceBuffers[index]
|
||||
let destination = destinationBuffers[index]
|
||||
guard let sourceData = source.mData, let destinationData = destination.mData else { continue }
|
||||
memcpy(destinationData, sourceData, Int(source.mDataByteSize))
|
||||
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
||||
}
|
||||
|
||||
return copy
|
||||
}
|
||||
|
||||
private func appendRecentMetric(_ value: TimeInterval, to metrics: inout [TimeInterval]) {
|
||||
guard value.isFinite, value > 0 else { return }
|
||||
metrics.append(value)
|
||||
if metrics.count > SuperFastCaptureConstants.callbackTimingWindowSize {
|
||||
metrics.removeFirst(metrics.count - SuperFastCaptureConstants.callbackTimingWindowSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
//
|
||||
// TranscriptionClient.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import HexCore
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionLogger = HexLog.transcription
|
||||
private let modelsLogger = HexLog.models
|
||||
private let parakeetLogger = HexLog.parakeet
|
||||
|
||||
/// A client that downloads and loads WhisperKit models, then transcribes audio files using the loaded model.
|
||||
/// Exposes progress callbacks to report overall download-and-load percentage and transcription progress.
|
||||
@DependencyClient
|
||||
struct TranscriptionClient {
|
||||
/// Transcribes an audio file at the specified `URL` using the named `model`.
|
||||
/// Reports transcription progress via `progressCallback`.
|
||||
var transcribe: @Sendable (URL, String, DecodingOptions, @escaping (Progress) -> Void) async throws -> String
|
||||
|
||||
/// Ensures a model is downloaded (if missing) and loaded into memory, reporting progress via `progressCallback`.
|
||||
var downloadModel: @Sendable (String, @escaping (Progress) -> Void) async throws -> Void
|
||||
|
||||
/// Deletes a model from disk if it exists
|
||||
var deleteModel: @Sendable (String) async throws -> Void
|
||||
|
||||
/// Checks if a named model is already downloaded on this system.
|
||||
var isModelDownloaded: @Sendable (String) async -> Bool = { _ in false }
|
||||
|
||||
/// Fetches a recommended set of models for the user's hardware from Hugging Face's `argmaxinc/whisperkit-coreml`.
|
||||
var getRecommendedModels: @Sendable () async throws -> ModelSupport
|
||||
|
||||
/// Lists all model variants found in `argmaxinc/whisperkit-coreml`.
|
||||
var getAvailableModels: @Sendable () async throws -> [String]
|
||||
}
|
||||
|
||||
extension TranscriptionClient: DependencyKey {
|
||||
static var liveValue: Self {
|
||||
let live = TranscriptionClientLive()
|
||||
return Self(
|
||||
transcribe: { try await live.transcribe(url: $0, model: $1, options: $2, progressCallback: $3) },
|
||||
downloadModel: { try await live.downloadAndLoadModel(variant: $0, progressCallback: $1) },
|
||||
deleteModel: { try await live.deleteModel(variant: $0) },
|
||||
isModelDownloaded: { await live.isModelDownloaded($0) },
|
||||
getRecommendedModels: { await live.getRecommendedModels() },
|
||||
getAvailableModels: { try await live.getAvailableModels() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var transcription: TranscriptionClient {
|
||||
get { self[TranscriptionClient.self] }
|
||||
set { self[TranscriptionClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// An `actor` that manages WhisperKit models by downloading (from Hugging Face),
|
||||
// loading them into memory, and then performing transcriptions.
|
||||
|
||||
actor TranscriptionClientLive {
|
||||
// MARK: - Stored Properties
|
||||
|
||||
/// The current in-memory `WhisperKit` instance, if any.
|
||||
private var whisperKit: WhisperKit?
|
||||
|
||||
/// The name of the currently loaded model, if any.
|
||||
private var currentModelName: String?
|
||||
private var parakeet: ParakeetClient = ParakeetClient()
|
||||
|
||||
/// The base folder under which we store model data (e.g., ~/Library/Application Support/...).
|
||||
private lazy var modelsBaseFolder: URL = {
|
||||
do {
|
||||
return try URL.hexModelsDirectory
|
||||
} catch {
|
||||
fatalError("Could not create Application Support folder: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Ensures the given `variant` model is downloaded and loaded, reporting
|
||||
/// overall progress (0%–50% for downloading, 50%–100% for loading).
|
||||
func downloadAndLoadModel(variant: String, progressCallback: @escaping (Progress) -> Void) async throws {
|
||||
// If Parakeet, use Parakeet client path
|
||||
if isParakeet(variant) {
|
||||
try await parakeet.ensureLoaded(modelName: variant, progress: progressCallback)
|
||||
currentModelName = variant
|
||||
return
|
||||
}
|
||||
// Resolve wildcard patterns (e.g., "distil*large-v3") to a concrete variant
|
||||
let variant = await resolveVariant(variant)
|
||||
// Special handling for corrupted or malformed variant names
|
||||
if variant.isEmpty {
|
||||
throw NSError(
|
||||
domain: "TranscriptionClient",
|
||||
code: -3,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Cannot download model: Empty model name",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
let overallProgress = Progress(totalUnitCount: 100)
|
||||
overallProgress.completedUnitCount = 0
|
||||
progressCallback(overallProgress)
|
||||
|
||||
modelsLogger.info("Preparing model download and load for \(variant)")
|
||||
|
||||
// 1) Model download phase (0-50% progress)
|
||||
if !(await isModelDownloaded(variant)) {
|
||||
try await downloadModelIfNeeded(variant: variant) { downloadProgress in
|
||||
let fraction = downloadProgress.fractionCompleted * 0.5
|
||||
overallProgress.completedUnitCount = Int64(fraction * 100)
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
} else {
|
||||
// Skip download phase if already downloaded
|
||||
overallProgress.completedUnitCount = 50
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
// 2) Model loading phase (50-100% progress)
|
||||
try await loadWhisperKitModel(variant) { loadingProgress in
|
||||
let fraction = 0.5 + (loadingProgress.fractionCompleted * 0.5)
|
||||
overallProgress.completedUnitCount = Int64(fraction * 100)
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
overallProgress.completedUnitCount = 100
|
||||
progressCallback(overallProgress)
|
||||
}
|
||||
|
||||
/// Deletes a model from disk if it exists
|
||||
func deleteModel(variant: String) async throws {
|
||||
if isParakeet(variant) {
|
||||
try await parakeet.deleteCaches(modelName: variant)
|
||||
if currentModelName == variant { unloadCurrentModel() }
|
||||
return
|
||||
}
|
||||
let modelFolder = modelPath(for: variant)
|
||||
|
||||
// Check if the model exists
|
||||
guard FileManager.default.fileExists(atPath: modelFolder.path) else {
|
||||
// Model doesn't exist, nothing to delete
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the currently loaded model, unload it first
|
||||
if currentModelName == variant {
|
||||
unloadCurrentModel()
|
||||
}
|
||||
|
||||
// Delete the model directory
|
||||
try FileManager.default.removeItem(at: modelFolder)
|
||||
|
||||
modelsLogger.info("Deleted model \(variant)")
|
||||
}
|
||||
|
||||
/// Returns `true` if the model is already downloaded to the local folder.
|
||||
/// Performs a thorough check to ensure the model files are actually present and usable.
|
||||
func isModelDownloaded(_ modelName: String) async -> Bool {
|
||||
if isParakeet(modelName) {
|
||||
let available = await parakeet.isModelAvailable(modelName)
|
||||
parakeetLogger.debug("Parakeet available? \(available)")
|
||||
return available
|
||||
}
|
||||
let modelFolderPath = modelPath(for: modelName).path
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// First, check if the basic model directory exists
|
||||
guard fileManager.fileExists(atPath: modelFolderPath) else {
|
||||
// Don't print logs that would spam the console
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
// Check if the directory has actual model files in it
|
||||
let contents = try fileManager.contentsOfDirectory(atPath: modelFolderPath)
|
||||
|
||||
// Model should have multiple files and certain key components
|
||||
guard !contents.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific model structure - need both tokenizer and model files
|
||||
let hasModelFiles = contents.contains { $0.hasSuffix(".mlmodelc") || $0.contains("model") }
|
||||
let tokenizerFolderPath = tokenizerPath(for: modelName).path
|
||||
let hasTokenizer = fileManager.fileExists(atPath: tokenizerFolderPath)
|
||||
|
||||
// Both conditions must be true for a model to be considered downloaded
|
||||
return hasModelFiles && hasTokenizer
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a list of recommended models based on current device hardware.
|
||||
func getRecommendedModels() async -> ModelSupport {
|
||||
await WhisperKit.recommendedRemoteModels()
|
||||
}
|
||||
|
||||
/// Lists all model variants available in the `argmaxinc/whisperkit-coreml` repository.
|
||||
func getAvailableModels() async throws -> [String] {
|
||||
var names = try await WhisperKit.fetchAvailableModels()
|
||||
#if canImport(FluidAudio)
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !names.contains(model.identifier) { names.insert(model.identifier, at: 0) }
|
||||
}
|
||||
#endif
|
||||
return names
|
||||
}
|
||||
|
||||
/// Transcribes the audio file at `url` using a `model` name.
|
||||
/// If the model is not yet loaded (or if it differs from the current model), it is downloaded and loaded first.
|
||||
/// Transcription progress can be monitored via `progressCallback`.
|
||||
func transcribe(
|
||||
url: URL,
|
||||
model: String,
|
||||
options: DecodingOptions,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws -> String {
|
||||
let startAll = Date()
|
||||
if isParakeet(model) {
|
||||
transcriptionLogger.notice("Transcribing with Parakeet model=\(model) file=\(url.lastPathComponent)")
|
||||
let startLoad = Date()
|
||||
try await downloadAndLoadModel(variant: model) { p in
|
||||
progressCallback(p)
|
||||
}
|
||||
transcriptionLogger.info("Parakeet ensureLoaded took \(String(format: "%.2f", Date().timeIntervalSince(startLoad)))s")
|
||||
let preparedClip = try ParakeetClipPreparer.ensureMinimumDuration(url: url, logger: parakeetLogger)
|
||||
defer { preparedClip.cleanup() }
|
||||
let startTx = Date()
|
||||
let text = try await parakeet.transcribe(preparedClip.url)
|
||||
transcriptionLogger.info("Parakeet transcription took \(String(format: "%.2f", Date().timeIntervalSince(startTx)))s")
|
||||
transcriptionLogger.info("Parakeet request total elapsed \(String(format: "%.2f", Date().timeIntervalSince(startAll)))s")
|
||||
return text
|
||||
}
|
||||
let model = await resolveVariant(model)
|
||||
// Load or switch to the required model if needed.
|
||||
if whisperKit == nil || model != currentModelName {
|
||||
unloadCurrentModel()
|
||||
let startLoad = Date()
|
||||
try await downloadAndLoadModel(variant: model) { p in
|
||||
// Debug logging, or scale as desired:
|
||||
progressCallback(p)
|
||||
}
|
||||
let loadDuration = Date().timeIntervalSince(startLoad)
|
||||
transcriptionLogger.info("WhisperKit ensureLoaded model=\(model) took \(String(format: "%.2f", loadDuration))s")
|
||||
}
|
||||
|
||||
guard let whisperKit = whisperKit else {
|
||||
throw NSError(
|
||||
domain: "TranscriptionClient",
|
||||
code: -1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to initialize WhisperKit for model: \(model)",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// Perform the transcription.
|
||||
transcriptionLogger.notice("Transcribing with WhisperKit model=\(model) file=\(url.lastPathComponent)")
|
||||
let startTx = Date()
|
||||
let results = try await whisperKit.transcribe(audioPath: url.path, decodeOptions: options)
|
||||
transcriptionLogger.info("WhisperKit transcription took \(String(format: "%.2f", Date().timeIntervalSince(startTx)))s")
|
||||
transcriptionLogger.info("WhisperKit request total elapsed \(String(format: "%.2f", Date().timeIntervalSince(startAll)))s")
|
||||
|
||||
// Concatenate results from all segments.
|
||||
let text = results.map(\.text).joined(separator: " ")
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Resolve wildcard patterns (e.g. "distil*large-v3") to a concrete model name.
|
||||
/// Preference: downloaded > non-turbo > any match.
|
||||
private func resolveVariant(_ variant: String) async -> String {
|
||||
guard variant.contains("*") || variant.contains("?") else { return variant }
|
||||
|
||||
let names: [String]
|
||||
do { names = try await WhisperKit.fetchAvailableModels() } catch { return variant }
|
||||
|
||||
// Build tuple array with download status for matching models
|
||||
var models: [(name: String, isDownloaded: Bool)] = []
|
||||
for name in names where ModelPatternMatcher.matches(variant, name) {
|
||||
models.append((name, await isModelDownloaded(name)))
|
||||
}
|
||||
|
||||
return ModelPatternMatcher.resolvePattern(variant, from: models) ?? variant
|
||||
}
|
||||
|
||||
private func isParakeet(_ name: String) -> Bool {
|
||||
ParakeetModel(rawValue: name) != nil
|
||||
}
|
||||
|
||||
/// Creates or returns the local folder (on disk) for a given `variant` model.
|
||||
private func modelPath(for variant: String) -> URL {
|
||||
// Remove any possible path traversal or invalid characters from variant name
|
||||
let sanitizedVariant = variant.components(separatedBy: CharacterSet(charactersIn: "./\\")).joined(separator: "_")
|
||||
|
||||
return modelsBaseFolder
|
||||
.appendingPathComponent("argmaxinc")
|
||||
.appendingPathComponent("whisperkit-coreml")
|
||||
.appendingPathComponent(sanitizedVariant, isDirectory: true)
|
||||
}
|
||||
|
||||
/// Creates or returns the local folder for the tokenizer files of a given `variant`.
|
||||
private func tokenizerPath(for variant: String) -> URL {
|
||||
modelPath(for: variant).appendingPathComponent("tokenizer", isDirectory: true)
|
||||
}
|
||||
|
||||
// Unloads any currently loaded model (clears `whisperKit` and `currentModelName`).
|
||||
private func unloadCurrentModel() {
|
||||
whisperKit = nil
|
||||
currentModelName = nil
|
||||
}
|
||||
|
||||
/// Downloads the model to a temporary folder (if it isn't already on disk),
|
||||
/// then moves it into its final folder in `modelsBaseFolder`.
|
||||
private func downloadModelIfNeeded(
|
||||
variant: String,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws {
|
||||
let modelFolder = modelPath(for: variant)
|
||||
|
||||
// If the model folder exists but isn't a complete model, clean it up
|
||||
let isDownloaded = await isModelDownloaded(variant)
|
||||
if FileManager.default.fileExists(atPath: modelFolder.path), !isDownloaded {
|
||||
try FileManager.default.removeItem(at: modelFolder)
|
||||
}
|
||||
|
||||
// If model is already fully downloaded, we're done
|
||||
if isDownloaded {
|
||||
return
|
||||
}
|
||||
|
||||
modelsLogger.info("Downloading model \(variant)")
|
||||
|
||||
// Create parent directories
|
||||
let parentDir = modelFolder.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
do {
|
||||
// Download directly using the exact variant name provided
|
||||
// WhisperKit 0.15.0 changed downloader params: passing
|
||||
// "argmaxinc/whisperkit-coreml" to a parameter interpreted as a host
|
||||
// yields NSURLErrorCannotFindHost in production builds that need
|
||||
// to fetch models for the first time. Let WhisperKit use its
|
||||
// default repo/host (Hugging Face) by omitting the repo/host arg.
|
||||
let tempFolder = try await WhisperKit.download(
|
||||
variant: variant,
|
||||
downloadBase: nil,
|
||||
useBackgroundSession: false,
|
||||
progressCallback: { progress in
|
||||
progressCallback(progress)
|
||||
}
|
||||
)
|
||||
|
||||
// Ensure target folder exists
|
||||
try FileManager.default.createDirectory(at: modelFolder, withIntermediateDirectories: true)
|
||||
|
||||
// Move the downloaded snapshot to the final location
|
||||
try moveContents(of: tempFolder, to: modelFolder)
|
||||
|
||||
modelsLogger.info("Downloaded model to \(modelFolder.path)")
|
||||
} catch {
|
||||
// Clean up any partial download if an error occurred
|
||||
FileManager.default.removeItemIfExists(at: modelFolder)
|
||||
|
||||
// Rethrow the original error
|
||||
modelsLogger.error("Error downloading model \(variant): \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a local model folder via `WhisperKitConfig`, optionally reporting load progress.
|
||||
private func loadWhisperKitModel(
|
||||
_ modelName: String,
|
||||
progressCallback: @escaping (Progress) -> Void
|
||||
) async throws {
|
||||
let loadingProgress = Progress(totalUnitCount: 100)
|
||||
loadingProgress.completedUnitCount = 0
|
||||
progressCallback(loadingProgress)
|
||||
|
||||
let modelFolder = modelPath(for: modelName)
|
||||
let tokenizerFolder = tokenizerPath(for: modelName)
|
||||
|
||||
// Use WhisperKit's config to load the model
|
||||
let config = WhisperKitConfig(
|
||||
model: modelName,
|
||||
modelFolder: modelFolder.path,
|
||||
tokenizerFolder: tokenizerFolder,
|
||||
// verbose: true,
|
||||
// logLevel: .debug,
|
||||
prewarm: false,
|
||||
load: true
|
||||
)
|
||||
|
||||
// The initializer automatically calls `loadModels`.
|
||||
whisperKit = try await WhisperKit(config)
|
||||
currentModelName = modelName
|
||||
|
||||
// Finalize load progress
|
||||
loadingProgress.completedUnitCount = 100
|
||||
progressCallback(loadingProgress)
|
||||
|
||||
modelsLogger.info("Loaded WhisperKit model \(modelName)")
|
||||
}
|
||||
|
||||
/// Moves all items from `sourceFolder` into `destFolder` (shallow move of directory contents).
|
||||
private func moveContents(of sourceFolder: URL, to destFolder: URL) throws {
|
||||
let fileManager = FileManager.default
|
||||
let items = try fileManager.contentsOfDirectory(atPath: sourceFolder.path)
|
||||
for item in items {
|
||||
let src = sourceFolder.appendingPathComponent(item)
|
||||
let dst = destFolder.appendingPathComponent(item)
|
||||
try fileManager.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
//
|
||||
// AppFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/26/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import SwiftUI
|
||||
|
||||
@Reducer
|
||||
struct AppFeature {
|
||||
private enum CancelID {
|
||||
case modelMissingFlash
|
||||
}
|
||||
|
||||
enum ActiveTab: Equatable {
|
||||
case settings
|
||||
case remappings
|
||||
case history
|
||||
case about
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State {
|
||||
var transcription: TranscriptionFeature.State = .init()
|
||||
var settings: SettingsFeature.State = .init()
|
||||
var history: HistoryFeature.State = .init()
|
||||
var activeTab: ActiveTab = .settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Permission state
|
||||
var microphonePermission: PermissionStatus = .notDetermined
|
||||
var accessibilityPermission: PermissionStatus = .notDetermined
|
||||
var inputMonitoringPermission: PermissionStatus = .notDetermined
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
case transcription(TranscriptionFeature.Action)
|
||||
case settings(SettingsFeature.Action)
|
||||
case history(HistoryFeature.Action)
|
||||
case setActiveTab(ActiveTab)
|
||||
case task
|
||||
case pasteLastTranscript
|
||||
|
||||
// Permission actions
|
||||
case checkPermissions
|
||||
case permissionsUpdated(mic: PermissionStatus, acc: PermissionStatus, input: PermissionStatus)
|
||||
case appActivated
|
||||
case modelStatusEvaluated(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.permissions) var permissions
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.transcription, action: \.transcription) {
|
||||
TranscriptionFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.settings, action: \.settings) {
|
||||
SettingsFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.history, action: \.history) {
|
||||
HistoryFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
return .merge(
|
||||
startPasteLastTranscriptMonitoring(),
|
||||
ensureSelectedModelReadiness(),
|
||||
startPermissionMonitoring()
|
||||
)
|
||||
|
||||
case .pasteLastTranscript:
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
guard let lastTranscript = transcriptionHistory.history.first?.text else {
|
||||
return .none
|
||||
}
|
||||
return .run { _ in
|
||||
await pasteboard.paste(lastTranscript)
|
||||
}
|
||||
|
||||
case .transcription(.modelMissing):
|
||||
HexLog.app.notice("Model missing - activating app and switching to settings")
|
||||
state.activeTab = .settings
|
||||
state.settings.shouldFlashModelSection = true
|
||||
return .run { send in
|
||||
await MainActor.run {
|
||||
HexLog.app.notice("Activating app for model missing")
|
||||
NotificationCenter.default.post(name: .presentSettingsWindow, object: nil)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await send(.settings(.set(\.shouldFlashModelSection, false)))
|
||||
}
|
||||
.cancellable(id: CancelID.modelMissingFlash, cancelInFlight: true)
|
||||
|
||||
case .transcription:
|
||||
return .none
|
||||
|
||||
case .settings(.requestMicrophone):
|
||||
return .run { send in
|
||||
_ = await permissions.requestMicrophone()
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
|
||||
case .settings(.requestAccessibility):
|
||||
return .run { send in
|
||||
await permissions.requestAccessibility()
|
||||
// Poll for status change (macOS doesn't provide callback)
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings(.requestInputMonitoring):
|
||||
return .run { send in
|
||||
_ = await permissions.requestInputMonitoring()
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
await send(.checkPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
case .settings:
|
||||
return .none
|
||||
|
||||
case .history(.navigateToSettings):
|
||||
state.activeTab = .settings
|
||||
return .none
|
||||
case .history:
|
||||
return .none
|
||||
case let .setActiveTab(tab):
|
||||
state.activeTab = tab
|
||||
return .none
|
||||
|
||||
// Permission handling
|
||||
case .checkPermissions:
|
||||
return .run { send in
|
||||
async let mic = permissions.microphoneStatus()
|
||||
async let acc = permissions.accessibilityStatus()
|
||||
async let input = permissions.inputMonitoringStatus()
|
||||
await send(.permissionsUpdated(mic: mic, acc: acc, input: input))
|
||||
}
|
||||
|
||||
case let .permissionsUpdated(mic, acc, input):
|
||||
state.microphonePermission = mic
|
||||
state.accessibilityPermission = acc
|
||||
state.inputMonitoringPermission = input
|
||||
return .none
|
||||
|
||||
case .appActivated:
|
||||
// App became active - re-check permissions
|
||||
return .send(.checkPermissions)
|
||||
|
||||
case .modelStatusEvaluated:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPasteLastTranscriptMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
let token = keyEventMonitor.handleKeyEvent { keyEvent in
|
||||
// Skip if user is setting a hotkey
|
||||
if isSettingPasteLastTranscriptHotkey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this matches the paste last transcript hotkey
|
||||
guard let pasteHotkey = hexSettings.pasteLastTranscriptHotkey,
|
||||
let key = keyEvent.key,
|
||||
key == pasteHotkey.key,
|
||||
keyEvent.modifiers.matchesExactly(pasteHotkey.modifiers) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trigger paste action - use MainActor to avoid escaping send
|
||||
MainActor.assumeIsolated {
|
||||
send(.pasteLastTranscript)
|
||||
}
|
||||
return true // Intercept the key event
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureSelectedModelReadiness() -> Effect<Action> {
|
||||
.run { send in
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
let selectedModel = hexSettings.selectedModel
|
||||
guard !selectedModel.isEmpty else {
|
||||
await send(.modelStatusEvaluated(false))
|
||||
return
|
||||
}
|
||||
let isReady = await transcription.isModelDownloaded(selectedModel)
|
||||
$modelBootstrapState.withLock { state in
|
||||
state.modelIdentifier = selectedModel
|
||||
if state.modelDisplayName?.isEmpty ?? true {
|
||||
state.modelDisplayName = selectedModel
|
||||
}
|
||||
state.isModelReady = isReady
|
||||
if isReady {
|
||||
state.lastError = nil
|
||||
state.progress = 1
|
||||
} else {
|
||||
state.progress = 0
|
||||
}
|
||||
}
|
||||
await send(.modelStatusEvaluated(isReady))
|
||||
}
|
||||
}
|
||||
|
||||
private func startPermissionMonitoring() -> Effect<Action> {
|
||||
.run { send in
|
||||
// Initial check on app launch
|
||||
await send(.checkPermissions)
|
||||
|
||||
// Monitor app activation events
|
||||
for await activation in permissions.observeAppActivation() {
|
||||
if case .didBecomeActive = activation {
|
||||
await send(.appActivated)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AppView: View {
|
||||
@Bindable var store: StoreOf<AppFeature>
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $store.activeTab) {
|
||||
Button {
|
||||
store.send(.setActiveTab(.settings))
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.settings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.remappings))
|
||||
} label: {
|
||||
Label("Transforms", systemImage: "text.badge.plus")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.remappings)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.history))
|
||||
} label: {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.history)
|
||||
|
||||
Button {
|
||||
store.send(.setActiveTab(.about))
|
||||
} label: {
|
||||
Label("About", systemImage: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tag(AppFeature.ActiveTab.about)
|
||||
}
|
||||
} detail: {
|
||||
switch store.state.activeTab {
|
||||
case .settings:
|
||||
SettingsView(
|
||||
store: store.scope(state: \.settings, action: \.settings),
|
||||
microphonePermission: store.microphonePermission,
|
||||
accessibilityPermission: store.accessibilityPermission,
|
||||
inputMonitoringPermission: store.inputMonitoringPermission
|
||||
)
|
||||
.navigationTitle("Settings")
|
||||
case .remappings:
|
||||
WordRemappingsView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("Transforms")
|
||||
case .history:
|
||||
HistoryView(store: store.scope(state: \.history, action: \.history))
|
||||
.navigationTitle("History")
|
||||
case .about:
|
||||
AboutView(store: store.scope(state: \.settings, action: \.settings))
|
||||
.navigationTitle("About")
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private let historyLogger = HexLog.history
|
||||
|
||||
// MARK: - Date Extensions
|
||||
|
||||
extension Date {
|
||||
func relativeFormatted() -> String {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
if calendar.isDateInToday(self) {
|
||||
return "Today"
|
||||
} else if calendar.isDateInYesterday(self) {
|
||||
return "Yesterday"
|
||||
} else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE" // Day of week
|
||||
return formatter.string(from: self)
|
||||
} else {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == FileStorageKey<TranscriptionHistory>.Default
|
||||
{
|
||||
static var transcriptionHistory: Self {
|
||||
Self[
|
||||
.fileStorage(.transcriptionHistoryURL),
|
||||
default: .init()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Migration
|
||||
|
||||
extension URL {
|
||||
static var transcriptionHistoryURL: URL {
|
||||
get {
|
||||
URL.hexMigratedFileURL(named: "transcription_history.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayerController: NSObject, AVAudioPlayerDelegate {
|
||||
private var player: AVAudioPlayer?
|
||||
private let (playbackFinishedStream, playbackFinishedContinuation) = AsyncStream<Void>.makeStream()
|
||||
|
||||
func play(url: URL) throws {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
self.player = player
|
||||
player.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
func waitForPlaybackToFinish() async {
|
||||
for await _ in playbackFinishedStream {}
|
||||
}
|
||||
|
||||
// AVAudioPlayerDelegate method
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
guard self.player === player else { return }
|
||||
self.player = nil
|
||||
finishPlayback()
|
||||
}
|
||||
|
||||
private func finishPlayback() {
|
||||
playbackFinishedContinuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History Feature
|
||||
|
||||
@Reducer
|
||||
struct HistoryFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
var playingTranscriptID: UUID?
|
||||
var playbackID: UUID?
|
||||
var audioPlayerController: AudioPlayerController?
|
||||
|
||||
mutating func stopAudioPlayback() {
|
||||
audioPlayerController?.stop()
|
||||
audioPlayerController = nil
|
||||
playingTranscriptID = nil
|
||||
playbackID = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case playTranscript(UUID)
|
||||
case stopPlayback
|
||||
case copyToClipboard(String)
|
||||
case deleteTranscript(UUID)
|
||||
case deleteAllTranscripts
|
||||
case confirmDeleteAll
|
||||
case playbackFinished(UUID)
|
||||
case navigateToSettings
|
||||
}
|
||||
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case let .playTranscript(id):
|
||||
if state.playingTranscriptID == id {
|
||||
// Stop playback if tapping the same transcript
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
}
|
||||
|
||||
// Stop any existing playback
|
||||
state.stopAudioPlayback()
|
||||
|
||||
// Find the transcript and play its audio
|
||||
guard let transcript = state.transcriptionHistory.history.first(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
do {
|
||||
let controller = AudioPlayerController()
|
||||
try controller.play(url: transcript.audioPath)
|
||||
let playbackID = UUID()
|
||||
|
||||
state.audioPlayerController = controller
|
||||
state.playingTranscriptID = id
|
||||
state.playbackID = playbackID
|
||||
|
||||
return .run { send in
|
||||
await controller.waitForPlaybackToFinish()
|
||||
await send(.playbackFinished(playbackID))
|
||||
}
|
||||
} catch {
|
||||
historyLogger.error("Failed to play audio: \(error.localizedDescription)")
|
||||
return .none
|
||||
}
|
||||
|
||||
case .stopPlayback:
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .playbackFinished(playbackID):
|
||||
guard state.playbackID == playbackID else { return .none }
|
||||
state.stopAudioPlayback()
|
||||
return .none
|
||||
|
||||
case let .copyToClipboard(text):
|
||||
return .run { [pasteboard] _ in
|
||||
await pasteboard.copy(text)
|
||||
}
|
||||
|
||||
case let .deleteTranscript(id):
|
||||
guard let index = state.transcriptionHistory.history.firstIndex(where: { $0.id == id }) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
let transcript = state.transcriptionHistory.history[index]
|
||||
|
||||
if state.playingTranscriptID == id {
|
||||
state.stopAudioPlayback()
|
||||
}
|
||||
|
||||
_ = state.$transcriptionHistory.withLock { history in
|
||||
history.history.remove(at: index)
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: [transcript])
|
||||
|
||||
case .deleteAllTranscripts:
|
||||
return .send(.confirmDeleteAll)
|
||||
|
||||
case .confirmDeleteAll:
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
state.stopAudioPlayback()
|
||||
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
|
||||
case .navigateToSettings:
|
||||
// This will be handled by the parent reducer
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptView: View {
|
||||
let transcript: Transcript
|
||||
let isPlaying: Bool
|
||||
let onPlay: () -> Void
|
||||
let onCopy: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(transcript.text)
|
||||
.font(.body)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.trailing, 40) // Space for buttons
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
// App icon and name
|
||||
if let bundleID = transcript.sourceAppBundleID,
|
||||
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
if let appName = transcript.sourceAppName {
|
||||
Text(appName)
|
||||
}
|
||||
Text("•")
|
||||
}
|
||||
|
||||
Image(systemName: "clock")
|
||||
Text(transcript.timestamp.relativeFormatted())
|
||||
Text("•")
|
||||
Text(transcript.timestamp.formatted(date: .omitted, time: .shortened))
|
||||
Text("•")
|
||||
Text(String(format: "%.1fs", transcript.duration))
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
onCopy()
|
||||
showCopyAnimation()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: showCopied ? "checkmark" : "doc.on.doc.fill")
|
||||
if showCopied {
|
||||
Text("Copied").font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(showCopied ? .green : .secondary)
|
||||
.help("Copy to clipboard")
|
||||
|
||||
Button(action: onPlay) {
|
||||
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isPlaying ? .blue : .secondary)
|
||||
.help(isPlaying ? "Stop playback" : "Play audio")
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Delete transcript")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.windowBackgroundColor).opacity(0.5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.onDisappear {
|
||||
// Clean up any running task when view disappears
|
||||
copyTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var showCopied = false
|
||||
@State private var copyTask: Task<Void, Error>?
|
||||
|
||||
private func showCopyAnimation() {
|
||||
copyTask?.cancel()
|
||||
|
||||
copyTask = Task {
|
||||
withAnimation {
|
||||
showCopied = true
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
withAnimation {
|
||||
showCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TranscriptView(
|
||||
transcript: Transcript(timestamp: Date(), text: "Hello, world!", audioPath: URL(fileURLWithPath: "/Users/langton/Downloads/test.m4a"), duration: 1.0),
|
||||
isPlaying: false,
|
||||
onPlay: {},
|
||||
onCopy: {},
|
||||
onDelete: {}
|
||||
)
|
||||
}
|
||||
|
||||
struct HistoryView: View {
|
||||
@ObserveInjection var inject
|
||||
let store: StoreOf<HistoryFeature>
|
||||
@State private var showingDeleteConfirmation = false
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !hexSettings.saveTranscriptionHistory {
|
||||
ContentUnavailableView {
|
||||
Label("History Disabled", systemImage: "clock.arrow.circlepath")
|
||||
} description: {
|
||||
Text("Transcription history is currently disabled.")
|
||||
} actions: {
|
||||
Button("Enable in Settings") {
|
||||
store.send(.navigateToSettings)
|
||||
}
|
||||
}
|
||||
} else if store.transcriptionHistory.history.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Transcriptions", systemImage: "text.bubble")
|
||||
} description: {
|
||||
Text("Your transcription history will appear here.")
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(store.transcriptionHistory.history) { transcript in
|
||||
TranscriptView(
|
||||
transcript: transcript,
|
||||
isPlaying: store.playingTranscriptID == transcript.id,
|
||||
onPlay: { store.send(.playTranscript(transcript.id)) },
|
||||
onCopy: { store.send(.copyToClipboard(transcript.text)) },
|
||||
onDelete: { store.send(.deleteTranscript(transcript.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.toolbar {
|
||||
Button(role: .destructive, action: { showingDeleteConfirmation = true }) {
|
||||
Label("Delete All", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert("Delete All Transcripts", isPresented: $showingDeleteConfirmation) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
store.send(.confirmDeleteAll)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all transcripts? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct WordRemappingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@FocusState private var isScratchpadFocused: Bool
|
||||
@State private var activeSection: ModificationSection = .removals
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
previewSection
|
||||
wordRulesSection
|
||||
outputFormattingSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
.onDisappear {
|
||||
store.send(.setRemappingScratchpadFocused(false))
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Preview")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Input")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Type or speak a sample…", text: $store.remappingScratchpadText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isScratchpadFocused)
|
||||
.onChange(of: isScratchpadFocused) { _, newValue in
|
||||
store.send(.setRemappingScratchpadFocused(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 27)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Output")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(previewText.isEmpty ? "—" : previewText)
|
||||
.frame(maxWidth: .infinity, minHeight: 22, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wordRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Word rules")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Rule type", selection: $activeSection) {
|
||||
ForEach(ModificationSection.allCases) { section in
|
||||
Text(section.title).tag(section)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 360)
|
||||
|
||||
switch activeSection {
|
||||
case .removals:
|
||||
removalsSection
|
||||
case .remappings:
|
||||
remappingsSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Remove matching words from every transcript.")
|
||||
.settingsCaption()
|
||||
Spacer()
|
||||
Toggle(
|
||||
"Enabled",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.wordRemovalsEnabled },
|
||||
set: { store.send(.setWordRemovalsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Divider()
|
||||
removalsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemovals.enumerated()), id: \.element.id) { index, removal in
|
||||
RemovalRow(removal: removalBinding(for: removal)) {
|
||||
store.send(.removeWordRemoval(removal.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemovals.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemoval)
|
||||
} label: {
|
||||
Label("Add pattern", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var remappingsSection: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Replace matching words in every transcript.")
|
||||
.settingsCaption()
|
||||
Divider()
|
||||
remappingsColumnHeaders
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(store.hexSettings.wordRemappings.enumerated()), id: \.element.id) { index, remapping in
|
||||
RemappingRow(remapping: remappingBinding(for: remapping)) {
|
||||
store.send(.removeWordRemapping(remapping.id))
|
||||
}
|
||||
if index < store.hexSettings.wordRemappings.count - 1 {
|
||||
Divider().padding(.leading, Layout.dividerLeadingPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.addWordRemapping)
|
||||
} label: {
|
||||
Label("Add replacement", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var outputFormattingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Output formatting")
|
||||
.font(.headline)
|
||||
|
||||
GroupBox {
|
||||
HStack(spacing: 20) {
|
||||
Toggle(
|
||||
"Lowercase output",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.lowercaseTranscripts },
|
||||
set: { store.send(.setLowercaseTranscripts($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Divider()
|
||||
.frame(height: 22)
|
||||
|
||||
Toggle(
|
||||
"Remove punctuation",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.removePunctuation },
|
||||
set: { store.send(.setRemovePunctuation($0)) }
|
||||
)
|
||||
)
|
||||
.toggleStyle(.switch)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var removalsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Pattern")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private var remappingsColumnHeaders: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("On")
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
Text("Match")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
Text("Replace")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer().frame(width: Layout.deleteColumnWidth)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
}
|
||||
|
||||
private func removalBinding(for removal: WordRemoval) -> Binding<WordRemoval> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemovals.first { $0.id == removal.id } ?? removal
|
||||
},
|
||||
set: { store.send(.updateWordRemoval($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private func remappingBinding(for remapping: WordRemapping) -> Binding<WordRemapping> {
|
||||
return Binding(
|
||||
get: {
|
||||
store.hexSettings.wordRemappings.first { $0.id == remapping.id } ?? remapping
|
||||
},
|
||||
set: { store.send(.updateWordRemapping($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
private var previewText: String {
|
||||
var output = store.remappingScratchpadText
|
||||
if store.hexSettings.wordRemovalsEnabled {
|
||||
output = WordRemovalApplier.apply(output, removals: store.hexSettings.wordRemovals)
|
||||
}
|
||||
output = WordRemappingApplier.apply(output, remappings: store.hexSettings.wordRemappings)
|
||||
output = TranscriptFormattingApplier.apply(
|
||||
output,
|
||||
lowercase: store.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: store.hexSettings.removePunctuation
|
||||
)
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemovalRow: View {
|
||||
@Binding var removal: WordRemoval
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $removal.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(removal.pattern.isEmpty ? "removal" : removal.pattern)")
|
||||
|
||||
TextField("Regex Pattern", text: $removal.pattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete removal")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemappingRow: View {
|
||||
@Binding var remapping: WordRemapping
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: $remapping.isEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.frame(width: Layout.toggleColumnWidth, alignment: .leading)
|
||||
.accessibilityLabel("Enable \(remapping.match.isEmpty ? "replacement" : remapping.match)")
|
||||
|
||||
TextField("Match", text: $remapping.match)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: Layout.arrowColumnWidth)
|
||||
|
||||
TextField("Replace", text: $remapping.replacement)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.frame(width: Layout.deleteColumnWidth)
|
||||
.help("Delete replacement")
|
||||
}
|
||||
.padding(.horizontal, Layout.rowHorizontalPadding)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ModificationSection: String, CaseIterable, Identifiable {
|
||||
case removals
|
||||
case remappings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .removals:
|
||||
return "Remove words"
|
||||
case .remappings:
|
||||
return "Replace words"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let toggleColumnWidth: CGFloat = 24
|
||||
static let deleteColumnWidth: CGFloat = 24
|
||||
static let arrowColumnWidth: CGFloat = 16
|
||||
static let rowHorizontalPadding: CGFloat = 10
|
||||
static let dividerLeadingPadding: CGFloat = toggleColumnWidth + rowHorizontalPadding + 8
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
struct AboutView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
@State var viewModel = CheckForUpdatesViewModel.shared
|
||||
@State private var showingChangelog = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Label("Version", systemImage: "info.circle")
|
||||
Spacer()
|
||||
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown")
|
||||
Button("Check for Updates") {
|
||||
viewModel.checkForUpdates()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!viewModel.canCheckForUpdates)
|
||||
}
|
||||
HStack {
|
||||
Label("Changelog", systemImage: "doc.text")
|
||||
Spacer()
|
||||
Button("Show Changelog") {
|
||||
showingChangelog.toggle()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.sheet(isPresented: $showingChangelog, onDismiss: {
|
||||
showingChangelog = false
|
||||
}) {
|
||||
ChangelogView()
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Label("Hex is open source", systemImage: "apple.terminal.on.rectangle")
|
||||
Spacer()
|
||||
Link("Visit our GitHub", destination: URL(string: "https://github.com/kitlangton/Hex/")!)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Label("Support the developer", systemImage: "heart")
|
||||
Spacer()
|
||||
Link("Become a Sponsor", destination: URL(string: "https://github.com/sponsors/kitlangton")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
import MarkdownUI
|
||||
|
||||
struct ChangelogView: View {
|
||||
@ObserveInjection var inject
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Changelog")
|
||||
.font(.title)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if let changelogPath = Bundle.main.path(forResource: "changelog", ofType: "md"),
|
||||
let changelogContent = try? String(
|
||||
contentsOfFile: changelogPath, encoding: .utf8)
|
||||
{
|
||||
Markdown(changelogContent)
|
||||
} else {
|
||||
Text("Changelog could not be loaded.")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Open on Login",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.openOnLogin },
|
||||
set: { store.send(.toggleOpenOnLogin($0)) }
|
||||
))
|
||||
} icon: {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Show Dock Icon",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.showDockIcon },
|
||||
set: { store.send(.toggleShowDockIcon($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "dock.rectangle")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Use clipboard to insert",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useClipboardPaste },
|
||||
set: { store.send(.setUseClipboardPaste($0)) }
|
||||
)
|
||||
)
|
||||
Text("Use clipboard to insert text. Fast but may not restore all clipboard content.\nTurn off to use simulated keypresses. Slower, but doesn't need to restore clipboard")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.doc.fill")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Copy to clipboard",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.copyToClipboard },
|
||||
set: { store.send(.setCopyToClipboard($0)) }
|
||||
)
|
||||
)
|
||||
Text("Copy transcription text to clipboard in addition to pasting it")
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Prevent System Sleep while Recording",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.preventSystemSleep },
|
||||
set: { store.send(.togglePreventSystemSleep($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "zzz")
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Super Fast Mode",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.superFastModeEnabled },
|
||||
set: { store.send(.toggleSuperFastMode($0)) }
|
||||
)
|
||||
)
|
||||
Text("Keep the microphone warm and prepend a short in-memory buffer for near-instant capture. macOS will keep showing the microphone indicator while this mode is armed.")
|
||||
} icon: {
|
||||
Image(systemName: "bolt.circle")
|
||||
}
|
||||
|
||||
Label {
|
||||
HStack(alignment: .center) {
|
||||
Text("Audio Behavior while Recording")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.recordingAudioBehavior },
|
||||
set: { store.send(.setRecordingAudioBehavior($0)) }
|
||||
)) {
|
||||
Label("Pause Media", systemImage: "pause")
|
||||
.tag(RecordingAudioBehavior.pauseMedia)
|
||||
Label("Mute Volume", systemImage: "speaker.slash")
|
||||
.tag(RecordingAudioBehavior.mute)
|
||||
Label("Do Nothing", systemImage: "hand.raised.slash")
|
||||
.tag(RecordingAudioBehavior.doNothing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
}
|
||||
} header: {
|
||||
Text("General")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import HexCore
|
||||
|
||||
struct HistorySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Label {
|
||||
Toggle("Save Transcription History", isOn: Binding(
|
||||
get: { store.hexSettings.saveTranscriptionHistory },
|
||||
set: { store.send(.toggleSaveTranscriptionHistory($0)) }
|
||||
))
|
||||
Text("Save transcriptions and audio recordings for later access")
|
||||
.settingsCaption()
|
||||
} icon: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
}
|
||||
|
||||
if store.hexSettings.saveTranscriptionHistory {
|
||||
Label {
|
||||
HStack {
|
||||
Text("Maximum History Entries")
|
||||
Spacer()
|
||||
Picker("", selection: Binding(
|
||||
get: { store.hexSettings.maxHistoryEntries ?? 0 },
|
||||
set: { newValue in
|
||||
store.send(.setMaxHistoryEntries(newValue == 0 ? nil : newValue))
|
||||
}
|
||||
)) {
|
||||
Text("Unlimited").tag(0)
|
||||
Text("50").tag(50)
|
||||
Text("100").tag(100)
|
||||
Text("200").tag(200)
|
||||
Text("500").tag(500)
|
||||
Text("1000").tag(1000)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "number.square")
|
||||
}
|
||||
|
||||
if store.hexSettings.maxHistoryEntries != nil {
|
||||
Text("Oldest entries will be automatically deleted when limit is reached")
|
||||
.settingsCaption()
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
PasteLastTranscriptHotkeyRow(store: store)
|
||||
}
|
||||
} header: {
|
||||
Text("History")
|
||||
} footer: {
|
||||
if !store.hexSettings.saveTranscriptionHistory {
|
||||
Text("When disabled, transcriptions will not be saved and audio files will be deleted immediately after transcription.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasteLastTranscriptHotkeyRow: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let pasteHotkey = store.hexSettings.pasteLastTranscriptHotkey
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Paste Last Transcript")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Assign a shortcut (modifier + key) to instantly paste your last transcription.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
}
|
||||
|
||||
let key = store.isSettingPasteLastTranscriptHotkey ? nil : pasteHotkey?.key
|
||||
let modifiers = store.isSettingPasteLastTranscriptHotkey ? store.currentPasteLastModifiers : (pasteHotkey?.modifiers ?? .init(modifiers: []))
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingPasteLastTranscriptHotkey)
|
||||
|
||||
if !store.isSettingPasteLastTranscriptHotkey, pasteHotkey == nil {
|
||||
Text("Not set")
|
||||
.settingsCaption()
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingPasteLastTranscriptHotkey)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if store.isSettingPasteLastTranscriptHotkey {
|
||||
Text("Use at least one modifier (⌘, ⌥, ⇧, ⌃) plus a key.")
|
||||
.settingsCaption()
|
||||
} else if pasteHotkey != nil {
|
||||
Button {
|
||||
store.send(.clearPasteLastTranscriptHotkey)
|
||||
} label: {
|
||||
Label("Clear shortcut", systemImage: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct HotKeySectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Section("Hot Key") {
|
||||
let hotKey = store.hexSettings.hotkey
|
||||
let key = store.isSettingHotKey ? nil : hotKey.key
|
||||
let modifiers = store.isSettingHotKey ? store.currentModifiers : hotKey.modifiers
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// Hot key view
|
||||
HStack {
|
||||
Spacer()
|
||||
HotKeyView(modifiers: modifiers, key: key, isActive: store.isSettingHotKey)
|
||||
.animation(.spring(), value: key)
|
||||
.animation(.spring(), value: modifiers)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.send(.startSettingHotKey)
|
||||
}
|
||||
|
||||
if !store.isSettingHotKey,
|
||||
hotKey.key == nil,
|
||||
!hotKey.modifiers.isEmpty {
|
||||
ModifierSideControls(
|
||||
modifiers: hotKey.modifiers,
|
||||
onSelect: { kind, side in
|
||||
store.send(.setModifierSide(kind, side))
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Toggle(
|
||||
"Enable double-tap lock",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.doubleTapLockEnabled },
|
||||
set: { store.send(.setDoubleTapLockEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap")
|
||||
}
|
||||
|
||||
// Double-tap only mode applies to key+modifier combinations.
|
||||
if hotKey.key != nil {
|
||||
Label {
|
||||
Toggle(
|
||||
"Use double-tap only",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.useDoubleTapOnly },
|
||||
set: { store.send(.setUseDoubleTapOnly($0)) }
|
||||
)
|
||||
)
|
||||
.disabled(!store.hexSettings.doubleTapLockEnabled)
|
||||
} icon: {
|
||||
Image(systemName: "hand.tap.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum key time (for modifier-only shortcuts)
|
||||
if store.hexSettings.hotkey.key == nil {
|
||||
Label {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { store.hexSettings.minimumKeyTime },
|
||||
set: { store.send(.setMinimumKeyTime($0)) }
|
||||
),
|
||||
in: 0.0 ... 2.0,
|
||||
step: 0.1
|
||||
) {
|
||||
Text("Ignore below \(store.hexSettings.minimumKeyTime, specifier: "%.1f")s")
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModifierSideControls: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var onSelect: (Modifier.Kind, Modifier.Side) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(modifiers.kinds, id: \.self) { kind in
|
||||
if kind.supportsSideSelection {
|
||||
let binding = Binding<Modifier.Side>(
|
||||
get: { modifiers.side(for: kind) ?? .either },
|
||||
set: { onSelect(kind, $0) }
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(kind.symbol) \(kind.displayName)")
|
||||
.settingsCaption()
|
||||
|
||||
Picker("Modifier side", selection: binding) {
|
||||
ForEach(Modifier.Side.allCases, id: \.self) { side in
|
||||
Text(side.displayName)
|
||||
.tag(side)
|
||||
.disabled(!kind.supportsSideSelection && side != .either)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// HotKeyView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/30/25.
|
||||
//
|
||||
|
||||
import HexCore
|
||||
import Inject
|
||||
import Sauce
|
||||
import SwiftUI
|
||||
|
||||
// This view shows the actual "keys" in a more modern, subtle style.
|
||||
struct HotKeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var modifiers: Modifiers
|
||||
var key: Key?
|
||||
var isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if modifiers.isHyperkey {
|
||||
// Show Black Four Pointed Star for hyperkey
|
||||
KeyView(text: "✦")
|
||||
.transition(.blurReplace)
|
||||
} else {
|
||||
ForEach(modifiers.sorted) { modifier in
|
||||
KeyView(text: modifier.stringValue)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
|
||||
if let key {
|
||||
KeyView(text: key.toString)
|
||||
}
|
||||
|
||||
if modifiers.isEmpty && key == nil {
|
||||
Text("")
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.frame(width: 48, height: 48)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isActive && key == nil && modifiers.isEmpty {
|
||||
Text("Enter a key combination")
|
||||
.foregroundColor(.secondary)
|
||||
.transition(.blurReplace)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.blue.opacity(isActive ? 0.1 : 0))
|
||||
.stroke(Color.blue.opacity(isActive ? 0.2 : 0), lineWidth: 1)
|
||||
)
|
||||
|
||||
.animation(.bouncy(duration: 0.3), value: key)
|
||||
.animation(.bouncy(duration: 0.3), value: modifiers)
|
||||
.animation(.bouncy(duration: 0.3), value: isActive)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyView: View {
|
||||
@ObserveInjection var inject
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
Color(white: 0.2)
|
||||
.shadow(.inner(color: .white.opacity(0.3), radius: 1, y: 1))
|
||||
.shadow(.inner(color: .white.opacity(0.1), radius: 5, y: 8))
|
||||
.shadow(.inner(color: .black.opacity(0.3), radius: 1, y: -3))
|
||||
)
|
||||
)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HotKeyView(
|
||||
modifiers: .init(modifiers: [.command, .shift]),
|
||||
key: .a,
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
import Inject
|
||||
#if canImport(ComposableArchitecture)
|
||||
import ComposableArchitecture
|
||||
#endif
|
||||
|
||||
struct LanguageSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Picker(
|
||||
"Output Language",
|
||||
selection: Binding(
|
||||
get: { store.hexSettings.outputLanguage },
|
||||
set: { store.send(.setOutputLanguage($0)) }
|
||||
)
|
||||
) {
|
||||
ForEach(store.languages, id: \.id) { language in
|
||||
Text(language.name).tag(language.code as String?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} icon: {
|
||||
Image(systemName: "globe")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct MicrophoneSelectionSectionView: View {
|
||||
private static let systemDefaultTag = "__system_default__"
|
||||
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
private var selectedDeviceTag: Binding<String> {
|
||||
Binding(
|
||||
get: { store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag },
|
||||
set: { newValue in
|
||||
store.send(.setSelectedMicrophoneID(newValue == Self.systemDefaultTag ? nil : newValue))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var missingSelectedDeviceID: String? {
|
||||
guard let selectedID = store.hexSettings.selectedMicrophoneID,
|
||||
!store.availableInputDevices.contains(where: { $0.id == selectedID })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectedID
|
||||
}
|
||||
|
||||
private var pickerIdentity: String {
|
||||
[
|
||||
store.defaultInputDeviceName ?? "",
|
||||
store.hexSettings.selectedMicrophoneID ?? Self.systemDefaultTag,
|
||||
store.availableInputDevices.map { "\($0.id):\($0.name)" }.joined(separator: "|")
|
||||
].joined(separator: "||")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
// Input device picker
|
||||
HStack {
|
||||
Label {
|
||||
let systemLabel: String = {
|
||||
if let name = store.defaultInputDeviceName, !name.isEmpty {
|
||||
return "System Default (\(name))"
|
||||
}
|
||||
return "System Default"
|
||||
}()
|
||||
Picker("Input Device", selection: selectedDeviceTag) {
|
||||
Text(systemLabel).tag(Self.systemDefaultTag)
|
||||
ForEach(store.availableInputDevices) { device in
|
||||
Text(device.name).tag(device.id)
|
||||
}
|
||||
if let missingSelectedDeviceID {
|
||||
Text("Unavailable Device").tag(missingSelectedDeviceID)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.id(pickerIdentity)
|
||||
} icon: {
|
||||
Image(systemName: "mic.circle")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
store.send(.loadAvailableInputDevices)
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh available input devices")
|
||||
}
|
||||
|
||||
// Show fallback note for selected device not connected
|
||||
if missingSelectedDeviceID != nil {
|
||||
Text("Selected device not connected. System default will be used.")
|
||||
.settingsCaption()
|
||||
}
|
||||
} header: {
|
||||
Text("Microphone Selection")
|
||||
} footer: {
|
||||
Text("Override the system default microphone with a specific input device. This setting will persist across sessions.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// MARK: – ModelDownloadFeature.swift
|
||||
|
||||
// A full‐featured TCA reducer for managing on‑device ML models.
|
||||
// Dependencies: ComposableArchitecture, IdentifiedCollections, Dependencies
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public struct ModelInfo: Equatable, Identifiable {
|
||||
public let name: String
|
||||
public var isDownloaded: Bool
|
||||
|
||||
public var id: String { name }
|
||||
public init(name: String, isDownloaded: Bool) {
|
||||
self.name = name
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
public struct CuratedModelInfo: Equatable, Identifiable, Codable {
|
||||
public let displayName: String
|
||||
public let internalName: String
|
||||
public let size: String
|
||||
public let accuracyStars: Int
|
||||
public let speedStars: Int
|
||||
public let storageSize: String
|
||||
public var isDownloaded: Bool
|
||||
public var id: String { internalName }
|
||||
|
||||
public var badge: String? {
|
||||
switch parakeetModel {
|
||||
case .englishV2:
|
||||
return "BEST FOR ENGLISH"
|
||||
case .multilingualV3:
|
||||
return "BEST FOR MULTILINGUAL"
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var parakeetModel: ParakeetModel? {
|
||||
ParakeetModel(rawValue: internalName)
|
||||
}
|
||||
|
||||
var isParakeet: Bool {
|
||||
parakeetModel != nil
|
||||
}
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
internalName: String,
|
||||
size: String,
|
||||
accuracyStars: Int,
|
||||
speedStars: Int,
|
||||
storageSize: String,
|
||||
isDownloaded: Bool
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.internalName = internalName
|
||||
self.size = size
|
||||
self.accuracyStars = accuracyStars
|
||||
self.speedStars = speedStars
|
||||
self.storageSize = storageSize
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
|
||||
// Codable (isDownloaded is set at runtime)
|
||||
private enum CodingKeys: String, CodingKey { case displayName, internalName, size, accuracyStars, speedStars, storageSize }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
displayName = try c.decode(String.self, forKey: .displayName)
|
||||
internalName = try c.decode(String.self, forKey: .internalName)
|
||||
size = try c.decode(String.self, forKey: .size)
|
||||
accuracyStars = try c.decode(Int.self, forKey: .accuracyStars)
|
||||
speedStars = try c.decode(Int.self, forKey: .speedStars)
|
||||
storageSize = try c.decode(String.self, forKey: .storageSize)
|
||||
isDownloaded = false
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience helper for loading the bundled models.json once.
|
||||
private enum CuratedModelLoader {
|
||||
private static let bundledModels: [CuratedModelInfo] = {
|
||||
guard let url = Bundle.main.url(forResource: "models", withExtension: "json") ??
|
||||
Bundle.main.url(forResource: "models", withExtension: "json", subdirectory: "Data")
|
||||
else {
|
||||
assertionFailure("models.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
do { return try JSONDecoder().decode([CuratedModelInfo].self, from: Data(contentsOf: url)) }
|
||||
catch { assertionFailure("Failed to decode models.json - \(error)"); return [] }
|
||||
}()
|
||||
|
||||
static func load() -> [CuratedModelInfo] {
|
||||
bundledModels
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Domain
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Reducer
|
||||
public struct ModelDownloadFeature {
|
||||
@ObservableState
|
||||
public struct State: Equatable {
|
||||
// Shared user settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Remote data
|
||||
public var availableModels: IdentifiedArrayOf<ModelInfo> = []
|
||||
public var curatedModels = IdentifiedArrayOf(uniqueElements: CuratedModelLoader.load())
|
||||
public var recommendedModel: String = ""
|
||||
|
||||
// UI state
|
||||
public var isLoadingModels = false
|
||||
public var isDownloading = false
|
||||
public var downloadProgress: Double = 0
|
||||
public var downloadError: String?
|
||||
public var downloadingModelName: String?
|
||||
|
||||
// Track which model generated a progress update to handle switching models
|
||||
public var activeDownloadID: UUID?
|
||||
|
||||
// Convenience computed vars
|
||||
var selectedModel: String { hexSettings.selectedModel }
|
||||
|
||||
/// The downloaded model matching the current selection, pattern-aware so
|
||||
/// legacy or glob-style selections (e.g. "distil*large-v3") still resolve.
|
||||
private var downloadedModelMatchingSelection: ModelInfo? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
return availableModels.first { model in
|
||||
model.isDownloaded && ModelPatternMatcher.namesMatch(model.name, selectedModel)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedModelNameForDisplay: String? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
if let downloaded = downloadedModelMatchingSelection {
|
||||
return downloaded.name
|
||||
}
|
||||
if modelBootstrapState.isModelReady,
|
||||
let identifier = modelBootstrapState.modelIdentifier,
|
||||
ModelPatternMatcher.namesMatch(identifier, selectedModel)
|
||||
{
|
||||
return selectedModel
|
||||
}
|
||||
return hexSettings.hasCompletedModelBootstrap ? selectedModel : nil
|
||||
}
|
||||
|
||||
var selectedModelIsDownloaded: Bool {
|
||||
downloadedModelMatchingSelection != nil
|
||||
}
|
||||
|
||||
var anyModelDownloaded: Bool {
|
||||
availableModels.contains(where: { $0.isDownloaded })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
public enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
// Requests
|
||||
case fetchModels
|
||||
case selectModel(String)
|
||||
case downloadModel(String)
|
||||
// Effects
|
||||
case modelsLoaded(recommended: String, available: [ModelInfo])
|
||||
case modelsLoadFailed
|
||||
case downloadProgress(id: UUID, progress: Double)
|
||||
case downloadCompleted(id: UUID, result: Result<String, Error>)
|
||||
case cancelDownload
|
||||
|
||||
case deleteModel(String)
|
||||
case modelDeleted(String)
|
||||
case modelDeletionFailed(Error)
|
||||
case openModelLocation(String)
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Reducer
|
||||
|
||||
public var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
Reduce(reduce)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (pattern matching)
|
||||
|
||||
private func resolvePattern(_ pattern: String, from available: [ModelInfo]) -> String? {
|
||||
ModelPatternMatcher.resolvePattern(pattern, from: available.map { ($0.name, $0.isDownloaded) })
|
||||
}
|
||||
|
||||
private func curatedDisplayName(for model: String, curated: IdentifiedArrayOf<CuratedModelInfo>) -> String {
|
||||
if let match = curated.first(where: { ModelPatternMatcher.matches($0.internalName, model) }) {
|
||||
return match.displayName
|
||||
}
|
||||
return model
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
|
||||
private func updateBootstrapState(_ state: inout State) {
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = ""
|
||||
bootstrap.modelDisplayName = ""
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.progress = 0
|
||||
bootstrap.lastError = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
let isDownloaded = state.selectedModelIsDownloaded
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = model
|
||||
bootstrap.modelDisplayName = displayName
|
||||
bootstrap.isModelReady = isDownloaded
|
||||
if isDownloaded {
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reduce(state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
// MARK: – UI bindings
|
||||
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case let .selectModel(model):
|
||||
// If the curated item is a glob (e.g., "distil*large-v3"),
|
||||
// resolve it to a concrete available model so both tabs stay in sync
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
// MARK: – Fetch Models
|
||||
|
||||
case .fetchModels:
|
||||
guard !state.isLoadingModels else { return .none }
|
||||
state.isLoadingModels = true
|
||||
return .run { send in
|
||||
do {
|
||||
async let recommendedSupportTask = transcription.getRecommendedModels()
|
||||
async let availableNamesTask = transcription.getAvailableModels()
|
||||
let recommendedSupport = try await recommendedSupportTask
|
||||
let names = try await availableNamesTask
|
||||
let recommended = recommendedSupport.default
|
||||
let infos = try await withThrowingTaskGroup(of: ModelInfo.self) { group -> [ModelInfo] in
|
||||
for name in names {
|
||||
group.addTask {
|
||||
ModelInfo(
|
||||
name: name,
|
||||
isDownloaded: await transcription.isModelDownloaded(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: []) { $0.append($1) }
|
||||
}
|
||||
await send(.modelsLoaded(recommended: recommended, available: infos))
|
||||
} catch {
|
||||
await send(.modelsLoadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
case .modelsLoadFailed:
|
||||
state.isLoadingModels = false
|
||||
return .none
|
||||
|
||||
case let .modelsLoaded(recommended, available):
|
||||
state.isLoadingModels = false
|
||||
// Ensure our curated Parakeet options are visible even if WhisperKit doesn't list them
|
||||
var availablePlus = available
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !availablePlus.contains(where: { $0.name == model.identifier }) {
|
||||
availablePlus.insert(ModelInfo(name: model.identifier, isDownloaded: false), at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if availablePlus.contains(where: { $0.name == state.preferredParakeetIdentifier }) {
|
||||
state.recommendedModel = state.preferredParakeetIdentifier
|
||||
} else {
|
||||
state.recommendedModel = recommended
|
||||
}
|
||||
state.availableModels = IdentifiedArrayOf(uniqueElements: availablePlus)
|
||||
|
||||
// If the selected model is a pattern, resolve it now to the first available match
|
||||
if state.hexSettings.selectedModel.contains("*") || state.hexSettings.selectedModel.contains("?") {
|
||||
if let resolved = resolvePattern(state.hexSettings.selectedModel, from: available) {
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
// Merge curated + download status with pattern support
|
||||
var curated = CuratedModelLoader.load()
|
||||
for idx in curated.indices {
|
||||
let internalName = curated[idx].internalName
|
||||
if let match = available.first(where: { ModelPatternMatcher.matches(internalName, $0.name) }) {
|
||||
curated[idx].isDownloaded = match.isDownloaded
|
||||
} else {
|
||||
curated[idx].isDownloaded = false
|
||||
}
|
||||
}
|
||||
state.curatedModels = IdentifiedArrayOf(uniqueElements: curated)
|
||||
// If the selection isn't installed but another model is, switch to the
|
||||
// installed one so transcription keeps working. Never clear the user's
|
||||
// selection outright: availability scans can produce false negatives
|
||||
// (e.g. after a dependency changes its cache layout), and wiping the
|
||||
// setting turns a transient glitch into a permanent silent failure.
|
||||
if !state.selectedModelIsDownloaded,
|
||||
let installedModel = state.curatedModels.first(where: \.isDownloaded)
|
||||
{
|
||||
let fallback = resolvePattern(installedModel.internalName, from: Array(state.availableModels)) ?? installedModel.internalName
|
||||
if fallback != state.selectedModel {
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
if !state.anyModelDownloaded && !state.hexSettings.hasCompletedModelBootstrap {
|
||||
let preferred = state.recommendedModel.isEmpty ? state.hexSettings.selectedModel : state.recommendedModel
|
||||
if !preferred.isEmpty {
|
||||
state.$hexSettings.withLock { $0.selectedModel = preferred }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
// MARK: – Download
|
||||
|
||||
case let .downloadModel(requestedModel):
|
||||
guard !requestedModel.isEmpty, !state.isDownloading else { return .none }
|
||||
// Resolve glob/legacy selections to a concrete model name up front so
|
||||
// the completion handler updates the matching rows and writes a
|
||||
// concrete name back into settings.
|
||||
let model = resolvePattern(requestedModel, from: Array(state.availableModels)) ?? requestedModel
|
||||
state.downloadError = nil
|
||||
state.isDownloading = true
|
||||
state.downloadProgress = 0
|
||||
state.downloadingModelName = model
|
||||
state.activeDownloadID = UUID()
|
||||
let downloadID = state.activeDownloadID!
|
||||
if !state.anyModelDownloaded {
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
state.$modelBootstrapState.withLock {
|
||||
$0.modelIdentifier = model
|
||||
$0.modelDisplayName = displayName
|
||||
$0.isModelReady = false
|
||||
$0.progress = 0
|
||||
$0.lastError = nil
|
||||
}
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.downloadModel(model) { progress in
|
||||
let fractionCompleted = progress.fractionCompleted
|
||||
Task {
|
||||
await send(.downloadProgress(id: downloadID, progress: fractionCompleted))
|
||||
}
|
||||
}
|
||||
await send(.downloadCompleted(id: downloadID, result: .success(model)))
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
await send(.downloadCompleted(id: downloadID, result: .failure(error)))
|
||||
}
|
||||
}
|
||||
.cancellable(id: downloadID)
|
||||
|
||||
case let .downloadProgress(id, progress):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
guard state.downloadProgress != progress else { return .none }
|
||||
state.downloadProgress = progress
|
||||
if !state.modelBootstrapState.isModelReady {
|
||||
state.$modelBootstrapState.withLock { $0.progress = progress }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .downloadCompleted(id, result):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
var failureMessage: String?
|
||||
switch result {
|
||||
case let .success(name):
|
||||
state.availableModels[id: name]?.isDownloaded = true
|
||||
if let idx = state.curatedModels.firstIndex(where: { $0.internalName == name }) {
|
||||
state.curatedModels[idx].isDownloaded = true
|
||||
}
|
||||
state.$hexSettings.withLock { settings in
|
||||
settings.selectedModel = name
|
||||
settings.hasCompletedModelBootstrap = true
|
||||
}
|
||||
state.downloadError = nil
|
||||
case let .failure(err):
|
||||
let ns = err as NSError
|
||||
var message = ns.localizedDescription
|
||||
if let url = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL,
|
||||
let host = url.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
} else if let str = ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String,
|
||||
let u = URL(string: str), let host = u.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
}
|
||||
state.downloadError = message
|
||||
failureMessage = message
|
||||
}
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
if let failureMessage {
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.lastError = failureMessage
|
||||
bootstrap.progress = 0
|
||||
} else {
|
||||
bootstrap.isModelReady = true
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case .cancelDownload:
|
||||
guard let id = state.activeDownloadID else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
state.$modelBootstrapState.withLock { $0.progress = 0 }
|
||||
updateBootstrapState(&state)
|
||||
return .cancel(id: id)
|
||||
|
||||
case let .deleteModel(model):
|
||||
guard !model.isEmpty else { return .none }
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
if ModelPatternMatcher.namesMatch(model, state.selectedModel) {
|
||||
state.$modelBootstrapState.withLock { $0.isModelReady = false }
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.deleteModel(resolved)
|
||||
await send(.modelDeleted(resolved))
|
||||
} catch {
|
||||
await send(.modelDeletionFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
case let .modelDeleted(model):
|
||||
state.availableModels[id: model]?.isDownloaded = false
|
||||
for index in state.curatedModels.indices
|
||||
where ModelPatternMatcher.matches(state.curatedModels[index].internalName, model) {
|
||||
state.curatedModels[index].isDownloaded = false
|
||||
}
|
||||
if ModelPatternMatcher.namesMatch(state.selectedModel, model) {
|
||||
let fallback = state.availableModels.first { $0.isDownloaded }?.name ?? ""
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
return .send(.fetchModels)
|
||||
|
||||
case let .modelDeletionFailed(error):
|
||||
state.downloadError = error.localizedDescription
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case let .openModelLocation(model):
|
||||
return openModelLocationEffect(for: model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func openModelLocationEffect(for model: String) -> Effect<Action> {
|
||||
// Parakeet caches live under FluidAudio's directory, not the WhisperKit
|
||||
// models folder. Route "Show in Finder" to the matching root so users
|
||||
// don't end up staring at an empty WhisperKit folder thinking the
|
||||
// Parakeet download silently failed.
|
||||
let usesParakeetRoot = ParakeetModel(rawValue: model) != nil
|
||||
return .run { _ in
|
||||
let base = try usesParakeetRoot
|
||||
? URL.hexParakeetModelsDirectory
|
||||
: URL.hexModelsDirectory
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: base.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelDownloadFeature.State {
|
||||
var preferredParakeetIdentifier: String {
|
||||
(prefersEnglishParakeet ? ParakeetModel.englishV2 : ParakeetModel.multilingualV3).identifier
|
||||
}
|
||||
|
||||
private var prefersEnglishParakeet: Bool {
|
||||
guard let language = hexSettings.outputLanguage?.lowercased(), !language.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return language.hasPrefix("en")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private func modelNamesMatch(_ lhs: String, _ rhs: String) -> Bool {
|
||||
ModelPatternMatcher.namesMatch(lhs, rhs)
|
||||
}
|
||||
|
||||
public struct ModelDownloadView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@State private var isModelLibraryPresented = false
|
||||
var shouldFlash: Bool = false
|
||||
|
||||
public init(store: StoreOf<ModelDownloadFeature>, shouldFlash: Bool = false) {
|
||||
self.store = store
|
||||
self.shouldFlash = shouldFlash
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !store.modelBootstrapState.isModelReady,
|
||||
let message = store.modelBootstrapState.lastError,
|
||||
!message.isEmpty
|
||||
{
|
||||
AutoDownloadBannerView(
|
||||
title: "Download failed",
|
||||
subtitle: message,
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
if selectedModelName == nil, downloadingModel == nil {
|
||||
NoModelChooser(
|
||||
models: parakeetModels,
|
||||
suggestedModel: suggestedParakeetIdentifier,
|
||||
isLoading: store.isLoadingModels,
|
||||
isFlashing: shouldFlash,
|
||||
onDownload: { store.send(.downloadModel($0.internalName)) },
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onRetry: { store.send(.fetchModels) }
|
||||
)
|
||||
} else {
|
||||
CurrentModelSummary(
|
||||
model: selectedModel,
|
||||
selectedModelName: selectedModelName,
|
||||
isInstalled: store.selectedModelIsDownloaded,
|
||||
isDownloadingAnything: store.isDownloading,
|
||||
downloadingModel: downloadingModel,
|
||||
downloadProgress: store.downloadProgress,
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onDownload: {
|
||||
if let name = selectedModelName {
|
||||
store.send(.downloadModel(name))
|
||||
}
|
||||
},
|
||||
onCancelDownload: { store.send(.cancelDownload) }
|
||||
)
|
||||
}
|
||||
if let err = store.downloadError,
|
||||
err != store.modelBootstrapState.lastError
|
||||
{
|
||||
Text("Model Error: \(err)")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: $isModelLibraryPresented) {
|
||||
ModelLibrarySheet(store: store)
|
||||
}
|
||||
.task {
|
||||
if store.availableModels.isEmpty {
|
||||
store.send(.fetchModels)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var selectedModel: CuratedModelInfo? {
|
||||
guard let selectedModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, selectedModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelName: String? {
|
||||
store.selectedModelNameForDisplay
|
||||
}
|
||||
|
||||
private var downloadingModel: CuratedModelInfo? {
|
||||
guard let downloadingModelName = store.downloadingModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, downloadingModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var parakeetModels: [CuratedModelInfo] {
|
||||
let models = store.curatedModels.filter(\.isParakeet)
|
||||
return models.filter { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } +
|
||||
models.filter { !modelNamesMatch($0.internalName, suggestedParakeetIdentifier) }
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelChooser: View {
|
||||
let models: [CuratedModelInfo]
|
||||
let suggestedModel: String
|
||||
let isLoading: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: (CuratedModelInfo) -> Void
|
||||
let onBrowse: () -> Void
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Choose a transcription model")
|
||||
.font(.body.weight(.medium))
|
||||
Text("Download a local model to start transcribing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("View All Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if models.isEmpty {
|
||||
if isLoading {
|
||||
Text("Loading model choices…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Model choices couldn’t be loaded.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry", action: onRetry)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(models) { model in
|
||||
NoModelCard(
|
||||
model: model,
|
||||
isRecommended: matches(model),
|
||||
isFlashing: isFlashing && matches(model),
|
||||
onDownload: { onDownload(model) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private func matches(_ model: CuratedModelInfo) -> Bool {
|
||||
modelNamesMatch(model.internalName, suggestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelCard: View {
|
||||
let model: CuratedModelInfo
|
||||
let isRecommended: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onDownload) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
if isRecommended {
|
||||
Text("Suggested")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.size)
|
||||
Text("·")
|
||||
Text(model.storageSize)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(NSColor.windowBackgroundColor).opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
if isRecommended { return Color.accentColor.opacity(0.45) }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CurrentModelSummary: View {
|
||||
let model: CuratedModelInfo?
|
||||
let selectedModelName: String?
|
||||
let isInstalled: Bool
|
||||
let isDownloadingAnything: Bool
|
||||
let downloadingModel: CuratedModelInfo?
|
||||
let downloadProgress: Double
|
||||
let onBrowse: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.accentColor)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.secondary)
|
||||
if let activeDownloadStatus {
|
||||
Text(activeDownloadStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if isDownloadingAnything {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isDownloadingAnything {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
if needsDownload {
|
||||
Button("Download", action: onDownload)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("Browse Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// The selection references a model that isn't on disk (e.g. the scan came
|
||||
/// back empty after an update). Offer a direct download instead of
|
||||
/// pretending it's installed.
|
||||
private var needsDownload: Bool {
|
||||
selectedModelName != nil && !isInstalled && !isDownloadingAnything
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
if let model { return model.displayName }
|
||||
if let selectedModelName {
|
||||
return selectedModelName
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
if let downloadingModel { return "Downloading \(downloadingModel.displayName)…" }
|
||||
return "Choose a transcription model"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if needsDownload { return "Not downloaded — transcription won't work until you download it." }
|
||||
if let model { return "\(model.size) · \(model.storageSize)" }
|
||||
if selectedModelName != nil { return "Installed local model" }
|
||||
if let downloadingModel { return "\(downloadingModel.storageSize) will be stored locally on this Mac." }
|
||||
return "Download a local model to start transcribing."
|
||||
}
|
||||
|
||||
private var activeDownloadStatus: String? {
|
||||
guard selectedModelName != nil, let downloadingModel else { return nil }
|
||||
return "Downloading \(downloadingModel.displayName)…"
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if needsDownload { return "exclamationmark.triangle.fill" }
|
||||
if selectedModelName == nil { return "arrow.down.circle.fill" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ModelLibrarySheet: View {
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var pendingDelete: CuratedModelInfo?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model Library")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Select an installed model to use it, or download another model for local transcription.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let recommendedLibraryModel {
|
||||
modelSection(title: "Recommended", models: [recommendedLibraryModel], showsBadges: false)
|
||||
}
|
||||
if !otherLibraryModels.isEmpty {
|
||||
modelSection(title: "Other Models", models: otherLibraryModels, showsBadges: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label("Models run locally on your Mac. Downloads are stored on this device.", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 680, minHeight: 420)
|
||||
.confirmationDialog(
|
||||
"Remove \(pendingDelete?.displayName ?? "model")?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pendingDelete {
|
||||
Button("Remove Download", role: .destructive) {
|
||||
store.send(.deleteModel(pendingDelete.internalName))
|
||||
self.pendingDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
if let pendingDelete {
|
||||
Text("This frees \(pendingDelete.storageSize) on this Mac. You can download it again anytime.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ model: CuratedModelInfo) {
|
||||
guard model.isDownloaded, !store.isDownloading else { return }
|
||||
store.send(.selectModel(model.internalName))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelSection(title: String, models: [CuratedModelInfo], showsBadges: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(models) { model in
|
||||
ModelLibraryRow(
|
||||
model: model,
|
||||
isSelected: model.isDownloaded && isSelected(model),
|
||||
isDownloading: isDownloading(model),
|
||||
// Only the active row receives live progress so the other
|
||||
// rows don't re-render on every tick.
|
||||
downloadProgress: isDownloading(model) ? store.downloadProgress : 0,
|
||||
isDisabled: store.isDownloading && !isDownloading(model),
|
||||
showsBadge: showsBadges && !model.isDownloaded,
|
||||
onSelect: { select(model) },
|
||||
onDownload: { store.send(.downloadModel(model.internalName)) },
|
||||
onCancelDownload: { store.send(.cancelDownload) },
|
||||
onShowInFinder: { store.send(.openModelLocation(model.internalName)) },
|
||||
onDelete: { pendingDelete = model }
|
||||
)
|
||||
if model.id != models.last?.id {
|
||||
Divider().padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private var recommendedLibraryModel: CuratedModelInfo? {
|
||||
store.curatedModels.first { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } ?? store.curatedModels.first(where: \.isParakeet)
|
||||
}
|
||||
|
||||
private var otherLibraryModels: [CuratedModelInfo] {
|
||||
guard let recommendedLibraryModel else { return Array(store.curatedModels) }
|
||||
return store.curatedModels.filter { $0.id != recommendedLibraryModel.id }
|
||||
}
|
||||
|
||||
private func isSelected(_ model: CuratedModelInfo) -> Bool {
|
||||
let selected = store.hexSettings.selectedModel
|
||||
return modelNamesMatch(model.internalName, selected)
|
||||
}
|
||||
|
||||
private func isDownloading(_ model: CuratedModelInfo) -> Bool {
|
||||
store.isDownloading && store.downloadingModelName == model.internalName
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelLibraryRow: View {
|
||||
let model: CuratedModelInfo
|
||||
let isSelected: Bool
|
||||
let isDownloading: Bool
|
||||
let downloadProgress: Double
|
||||
let isDisabled: Bool
|
||||
let showsBadge: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
let onShowInFinder: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: leadingIcon)
|
||||
.foregroundStyle(leadingColor)
|
||||
.font(.body)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
if showsBadge, let badge = model.badge {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(model.size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 5) {
|
||||
Text("Accuracy").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.accuracyStars)
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
Text("Speed").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.speedStars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(model.storageSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
trailingControls
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.55 : 1)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.06) : Color.clear)
|
||||
.contextMenu {
|
||||
if isDownloading {
|
||||
Button("Cancel Download", role: .destructive, action: onCancelDownload)
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
}
|
||||
if model.isDownloaded {
|
||||
managementMenuItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by the trailing ⋯ menu and the right-click context menu.
|
||||
@ViewBuilder
|
||||
private var managementMenuItems: some View {
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
Divider()
|
||||
Button("Remove Download…", role: .destructive, action: onDelete)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if isDownloading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if model.isDownloaded {
|
||||
Menu {
|
||||
managementMenuItems
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help("Show in Finder or remove this download")
|
||||
} else {
|
||||
Button("Download", action: onDownload)
|
||||
.controlSize(.small)
|
||||
.help("Download \(model.storageSize) and switch to this model")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String? {
|
||||
if isDownloading { return "Downloading…" }
|
||||
if isSelected { return "In Use" }
|
||||
if model.isDownloaded { return "Installed" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var leadingIcon: String {
|
||||
if isDownloading { return "arrow.down.circle.fill" }
|
||||
if isSelected { return "checkmark.circle.fill" }
|
||||
if model.isDownloaded { return "circle" }
|
||||
return "arrow.down.circle"
|
||||
}
|
||||
|
||||
private var leadingColor: Color {
|
||||
if isDownloading || isSelected { return .accentColor }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct StarRatingView: View {
|
||||
@ObserveInjection var inject
|
||||
let filled: Int
|
||||
let max: Int
|
||||
|
||||
init(_ filled: Int, max: Int = 5) {
|
||||
self.filled = filled
|
||||
self.max = max
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0 ..< max, id: \.self) { i in
|
||||
Image(systemName: i < filled ? "circle.fill" : "circle")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ComposableArchitecture
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct ModelSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let shouldFlash: Bool
|
||||
|
||||
var body: some View {
|
||||
Section("Transcription Model") {
|
||||
ModelDownloadView(
|
||||
store: store.scope(state: \.modelDownload, action: \.modelDownload),
|
||||
shouldFlash: shouldFlash
|
||||
)
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
// Microphone
|
||||
permissionCard(
|
||||
title: "Microphone",
|
||||
icon: "mic.fill",
|
||||
status: microphonePermission,
|
||||
action: { store.send(.requestMicrophone) }
|
||||
)
|
||||
|
||||
// Accessibility + Keyboard
|
||||
permissionCard(
|
||||
title: "Accessibility",
|
||||
icon: "accessibility",
|
||||
status: combinedAccessibilityStatus,
|
||||
action: {
|
||||
store.send(.requestAccessibility)
|
||||
store.send(.requestInputMonitoring)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if store.hotkeyPermissionState.inputMonitoring != .granted {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label {
|
||||
Text("Input Monitoring is required so Hex can listen for your hotkey.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Button {
|
||||
store.send(.requestInputMonitoring)
|
||||
} label: {
|
||||
Text("Open Input Monitoring Settings")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
} header: {
|
||||
Text("Permissions")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func permissionCard(
|
||||
title: String,
|
||||
icon: String,
|
||||
status: PermissionStatus,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 16)
|
||||
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
switch status {
|
||||
case .granted:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.body)
|
||||
case .denied, .notDetermined:
|
||||
Button("Grant") {
|
||||
action()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var combinedAccessibilityStatus: PermissionStatus {
|
||||
if accessibilityPermission == .granted && inputMonitoringPermission == .granted {
|
||||
return .granted
|
||||
}
|
||||
if accessibilityPermission == .denied || inputMonitoringPermission == .denied {
|
||||
return .denied
|
||||
}
|
||||
return .notDetermined
|
||||
}
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import CoreAudio
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
import Sauce
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
private let settingsLogger = HexLog.settings
|
||||
private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer<AudioObjectPropertyAddress>) -> Void
|
||||
|
||||
private enum HotKeyCaptureTarget {
|
||||
case recording
|
||||
case pasteLastTranscript
|
||||
}
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == InMemoryKey<Bool>.Default
|
||||
{
|
||||
static var isSettingHotKey: Self {
|
||||
Self[.inMemory("isSettingHotKey"), default: false]
|
||||
}
|
||||
|
||||
static var isSettingPasteLastTranscriptHotkey: Self {
|
||||
Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false]
|
||||
}
|
||||
|
||||
static var isRemappingScratchpadFocused: Self {
|
||||
Self[.inMemory("isRemappingScratchpadFocused"), default: false]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Feature
|
||||
|
||||
@Reducer
|
||||
struct SettingsFeature {
|
||||
@ObservableState
|
||||
struct State {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool = false
|
||||
@Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
@Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState
|
||||
|
||||
var languages: IdentifiedArrayOf<Language> = []
|
||||
var currentModifiers: Modifiers = .init(modifiers: [])
|
||||
var currentPasteLastModifiers: Modifiers = .init(modifiers: [])
|
||||
var remappingScratchpadText: String = ""
|
||||
|
||||
// Available microphones
|
||||
var availableInputDevices: [AudioInputDevice] = []
|
||||
var defaultInputDeviceName: String?
|
||||
|
||||
// Model Management
|
||||
var modelDownload = ModelDownloadFeature.State()
|
||||
var shouldFlashModelSection = false
|
||||
|
||||
}
|
||||
|
||||
enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
|
||||
// Existing
|
||||
case task
|
||||
case startSettingHotKey
|
||||
case startSettingPasteLastTranscriptHotkey
|
||||
case clearPasteLastTranscriptHotkey
|
||||
case keyEvent(KeyEvent)
|
||||
case toggleOpenOnLogin(Bool)
|
||||
case toggleShowDockIcon(Bool)
|
||||
case togglePreventSystemSleep(Bool)
|
||||
case setRecordingAudioBehavior(RecordingAudioBehavior)
|
||||
case toggleSuperFastMode(Bool)
|
||||
case setUseClipboardPaste(Bool)
|
||||
case setCopyToClipboard(Bool)
|
||||
case setDoubleTapLockEnabled(Bool)
|
||||
case setUseDoubleTapOnly(Bool)
|
||||
case setMinimumKeyTime(Double)
|
||||
case setOutputLanguage(String?)
|
||||
case setSelectedMicrophoneID(String?)
|
||||
case setSoundEffectsEnabled(Bool)
|
||||
case setSoundEffectsVolume(Double)
|
||||
|
||||
// Permission delegation (forwarded to AppFeature)
|
||||
case requestMicrophone
|
||||
case requestAccessibility
|
||||
case requestInputMonitoring
|
||||
|
||||
// Microphone selection
|
||||
case loadAvailableInputDevices
|
||||
case availableInputDevicesLoaded([AudioInputDevice], String?)
|
||||
|
||||
// Model Management
|
||||
case modelDownload(ModelDownloadFeature.Action)
|
||||
|
||||
// History Management
|
||||
case toggleSaveTranscriptionHistory(Bool)
|
||||
case setMaxHistoryEntries(Int?)
|
||||
|
||||
// Modifier configuration
|
||||
case setModifierSide(Modifier.Kind, Modifier.Side)
|
||||
|
||||
// Word remappings
|
||||
case setWordRemovalsEnabled(Bool)
|
||||
case addWordRemoval
|
||||
case updateWordRemoval(WordRemoval)
|
||||
case removeWordRemoval(UUID)
|
||||
case addWordRemapping
|
||||
case updateWordRemapping(WordRemapping)
|
||||
case removeWordRemapping(UUID)
|
||||
case setRemappingScratchpadFocused(Bool)
|
||||
case setLowercaseTranscripts(Bool)
|
||||
case setRemovePunctuation(Bool)
|
||||
}
|
||||
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.soundEffects) var soundEffects
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect<Action> {
|
||||
.run { [transcriptPersistence] _ in
|
||||
for transcript in transcripts {
|
||||
try? await transcriptPersistence.deleteAudio(transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = true }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$isSettingHotKey.withLock { $0 = false }
|
||||
state.currentModifiers = .init(modifiers: [])
|
||||
case .pasteLastTranscript:
|
||||
state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false }
|
||||
state.currentPasteLastModifiers = .init(modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func captureModifiers(for target: HotKeyCaptureTarget, state: State) -> Modifiers {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCaptureModifiers(_ modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.currentModifiers = modifiers
|
||||
case .pasteLastTranscript:
|
||||
state.currentPasteLastModifiers = modifiers
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCapturedHotKey(key: Key?, modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) {
|
||||
switch target {
|
||||
case .recording:
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.key = key
|
||||
$0.hotkey.modifiers = modifiers.erasingSides()
|
||||
}
|
||||
case .pasteLastTranscript:
|
||||
guard let key else { return }
|
||||
state.$hexSettings.withLock {
|
||||
$0.pasteLastTranscriptHotkey = HotKey(key: key, modifiers: modifiers.erasingSides())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect<Action> {
|
||||
if keyEvent.key == .escape {
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
let updatedModifiers = keyEvent.modifiers.union(captureModifiers(for: target, state: state))
|
||||
updateCaptureModifiers(updatedModifiers, for: target, state: &state)
|
||||
|
||||
if target == .pasteLastTranscript, keyEvent.key != nil, updatedModifiers.isEmpty {
|
||||
return .none
|
||||
}
|
||||
|
||||
if let key = keyEvent.key {
|
||||
applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
return .none
|
||||
}
|
||||
|
||||
if target == .recording, keyEvent.modifiers.isEmpty {
|
||||
applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state)
|
||||
endCapture(target, state: &state)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
|
||||
Scope(state: \.modelDownload, action: \.modelDownload) {
|
||||
ModelDownloadFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .binding:
|
||||
let didNormalizeDoubleTapOnly = !state.hexSettings.doubleTapLockEnabled && state.hexSettings.useDoubleTapOnly
|
||||
if didNormalizeDoubleTapOnly {
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case .task:
|
||||
if let url = Bundle.main.url(forResource: "languages", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let languages = try? JSONDecoder().decode([Language].self, from: data)
|
||||
{
|
||||
state.languages = IdentifiedArray(uniqueElements: languages)
|
||||
} else {
|
||||
settingsLogger.error("Failed to load languages JSON from bundle")
|
||||
}
|
||||
|
||||
// Listen for key events and load microphones (existing + new)
|
||||
return .run { send in
|
||||
func audioPropertyAddress(
|
||||
_ selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
|
||||
) -> AudioObjectPropertyAddress {
|
||||
AudioObjectPropertyAddress(
|
||||
mSelector: selector,
|
||||
mScope: scope,
|
||||
mElement: element
|
||||
)
|
||||
}
|
||||
|
||||
await send(.modelDownload(.fetchModels))
|
||||
await send(.loadAvailableInputDevices)
|
||||
|
||||
// Listen for device connection/disconnection notifications
|
||||
// Using a simpler debounced approach with a single task
|
||||
var deviceUpdateTask: Task<Void, Never>?
|
||||
var audioHardwareObservers: [(AudioObjectPropertySelector, SettingsAudioPropertyListenerBlock)] = []
|
||||
|
||||
// Helper function to debounce device updates
|
||||
func debounceDeviceUpdate() {
|
||||
deviceUpdateTask?.cancel()
|
||||
deviceUpdateTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 500ms
|
||||
if !Task.isCancelled {
|
||||
await send(.loadAvailableInputDevices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installAudioHardwareObserver(_ selector: AudioObjectPropertySelector) {
|
||||
let listener: SettingsAudioPropertyListenerBlock = { _, _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectAddPropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
|
||||
if status == noErr {
|
||||
audioHardwareObservers.append((selector, listener))
|
||||
} else {
|
||||
settingsLogger.error("Failed to observe audio hardware selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
let deviceConnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let deviceDisconnectionObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"),
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let appDidBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
let wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
debounceDeviceUpdate()
|
||||
}
|
||||
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDefaultInputDevice)
|
||||
installAudioHardwareObserver(kAudioHardwarePropertyDevices)
|
||||
|
||||
// Be sure to clean up resources when the task is finished
|
||||
defer {
|
||||
deviceUpdateTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(deviceConnectionObserver)
|
||||
NotificationCenter.default.removeObserver(deviceDisconnectionObserver)
|
||||
NotificationCenter.default.removeObserver(appDidBecomeActiveObserver)
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
|
||||
|
||||
for (selector, listener) in audioHardwareObservers {
|
||||
var address = audioPropertyAddress(selector)
|
||||
let status = AudioObjectRemovePropertyListenerBlock(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
DispatchQueue.main,
|
||||
listener
|
||||
)
|
||||
if status != noErr {
|
||||
settingsLogger.error("Failed to remove audio hardware observer for selector \(selector): \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await keyEvent in await keyEventMonitor.listenForKeyPress() {
|
||||
await send(.keyEvent(keyEvent))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .startSettingHotKey:
|
||||
beginCapture(.recording, state: &state)
|
||||
return .none
|
||||
|
||||
case .addWordRemoval:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.append(.init(pattern: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemoval(removal):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemovals.firstIndex(where: { $0.id == removal.id }) else { return }
|
||||
$0.wordRemovals[index] = removal
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemoval(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemovals.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case .addWordRemapping:
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.append(.init(match: "", replacement: ""))
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .updateWordRemapping(remapping):
|
||||
state.$hexSettings.withLock {
|
||||
guard let index = $0.wordRemappings.firstIndex(where: { $0.id == remapping.id }) else { return }
|
||||
$0.wordRemappings[index] = remapping
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .removeWordRemapping(id):
|
||||
state.$hexSettings.withLock {
|
||||
$0.wordRemappings.removeAll { $0.id == id }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setRemappingScratchpadFocused(isFocused):
|
||||
state.$isRemappingScratchpadFocused.withLock { $0 = isFocused }
|
||||
return .none
|
||||
|
||||
case let .setLowercaseTranscripts(enabled):
|
||||
state.$hexSettings.withLock { $0.lowercaseTranscripts = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRemovePunctuation(enabled):
|
||||
state.$hexSettings.withLock { $0.removePunctuation = enabled }
|
||||
return .none
|
||||
|
||||
case .startSettingPasteLastTranscriptHotkey:
|
||||
beginCapture(.pasteLastTranscript, state: &state)
|
||||
return .none
|
||||
|
||||
case .clearPasteLastTranscriptHotkey:
|
||||
state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil }
|
||||
return .none
|
||||
|
||||
case let .keyEvent(keyEvent):
|
||||
if state.isSettingPasteLastTranscriptHotkey {
|
||||
return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state)
|
||||
}
|
||||
|
||||
guard state.isSettingHotKey else { return .none }
|
||||
return handleCapture(keyEvent, for: .recording, state: &state)
|
||||
|
||||
case let .toggleOpenOnLogin(enabled):
|
||||
state.$hexSettings.withLock { $0.openOnLogin = enabled }
|
||||
return .run { _ in
|
||||
if enabled {
|
||||
try? SMAppService.mainApp.register()
|
||||
} else {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
case let .toggleShowDockIcon(enabled):
|
||||
state.$hexSettings.withLock { $0.showDockIcon = enabled }
|
||||
return .run { _ in
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .updateAppMode, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
case let .togglePreventSystemSleep(enabled):
|
||||
state.$hexSettings.withLock { $0.preventSystemSleep = enabled }
|
||||
return .none
|
||||
|
||||
case let .setUseClipboardPaste(enabled):
|
||||
state.$hexSettings.withLock { $0.useClipboardPaste = enabled }
|
||||
return .none
|
||||
|
||||
case let .setCopyToClipboard(enabled):
|
||||
state.$hexSettings.withLock { $0.copyToClipboard = enabled }
|
||||
return .none
|
||||
|
||||
case let .setRecordingAudioBehavior(behavior):
|
||||
state.$hexSettings.withLock { $0.recordingAudioBehavior = behavior }
|
||||
return .none
|
||||
|
||||
case let .toggleSuperFastMode(enabled):
|
||||
state.$hexSettings.withLock { $0.superFastModeEnabled = enabled }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setDoubleTapLockEnabled(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.doubleTapLockEnabled = enabled
|
||||
if !enabled {
|
||||
$0.useDoubleTapOnly = false
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setUseDoubleTapOnly(enabled):
|
||||
state.$hexSettings.withLock {
|
||||
$0.useDoubleTapOnly = enabled && $0.doubleTapLockEnabled
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setMinimumKeyTime(value):
|
||||
state.$hexSettings.withLock { $0.minimumKeyTime = value }
|
||||
return .none
|
||||
|
||||
case let .setOutputLanguage(language):
|
||||
state.$hexSettings.withLock { $0.outputLanguage = language }
|
||||
return .none
|
||||
|
||||
case let .setSelectedMicrophoneID(deviceID):
|
||||
state.$hexSettings.withLock { $0.selectedMicrophoneID = deviceID }
|
||||
return .run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
|
||||
case let .setSoundEffectsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.soundEffectsEnabled = enabled }
|
||||
return .run { _ in
|
||||
await soundEffects.setEnabled(enabled)
|
||||
}
|
||||
|
||||
case let .setSoundEffectsVolume(volume):
|
||||
state.$hexSettings.withLock { $0.soundEffectsVolume = volume }
|
||||
return .none
|
||||
|
||||
// Permission requests
|
||||
case .requestMicrophone:
|
||||
settingsLogger.info("User requested microphone permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestAccessibility:
|
||||
settingsLogger.info("User requested accessibility permission from settings")
|
||||
return .none
|
||||
|
||||
case .requestInputMonitoring:
|
||||
settingsLogger.info("User requested input monitoring permission from settings")
|
||||
return .none
|
||||
|
||||
// Model Management
|
||||
case .modelDownload:
|
||||
return .none
|
||||
|
||||
// Microphone device selection
|
||||
case .loadAvailableInputDevices:
|
||||
return .run { send in
|
||||
let devices = await recording.getAvailableInputDevices()
|
||||
let defaultName = await recording.getDefaultInputDeviceName()
|
||||
await send(.availableInputDevicesLoaded(devices, defaultName))
|
||||
}
|
||||
|
||||
case let .availableInputDevicesLoaded(devices, defaultName):
|
||||
if let selectedMicrophoneID = state.hexSettings.selectedMicrophoneID,
|
||||
let device = devices.first(where: { $0.legacyID == selectedMicrophoneID }) {
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .send(.setSelectedMicrophoneID(device.id))
|
||||
}
|
||||
state.availableInputDevices = devices
|
||||
state.defaultInputDeviceName = defaultName
|
||||
return .none
|
||||
|
||||
case let .toggleSaveTranscriptionHistory(enabled):
|
||||
state.$hexSettings.withLock { $0.saveTranscriptionHistory = enabled }
|
||||
|
||||
// If disabling history, delete all existing entries
|
||||
if !enabled {
|
||||
let transcripts = state.transcriptionHistory.history
|
||||
|
||||
// Clear the history
|
||||
state.$transcriptionHistory.withLock { history in
|
||||
history.history.removeAll()
|
||||
}
|
||||
|
||||
return deleteAudioEffect(for: transcripts)
|
||||
}
|
||||
|
||||
return .none
|
||||
|
||||
case let .setMaxHistoryEntries(maxHistoryEntries):
|
||||
state.$hexSettings.withLock { $0.maxHistoryEntries = maxHistoryEntries }
|
||||
return .none
|
||||
|
||||
case let .setModifierSide(kind, side):
|
||||
guard state.hexSettings.hotkey.key == nil else { return .none }
|
||||
state.$hexSettings.withLock {
|
||||
$0.hotkey.modifiers = $0.hotkey.modifiers.setting(kind: kind, to: side)
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .setWordRemovalsEnabled(enabled):
|
||||
state.$hexSettings.withLock { $0.wordRemovalsEnabled = enabled }
|
||||
return .none
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
let microphonePermission: PermissionStatus
|
||||
let accessibilityPermission: PermissionStatus
|
||||
let inputMonitoringPermission: PermissionStatus
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if microphonePermission != .granted
|
||||
|| accessibilityPermission != .granted
|
||||
|| inputMonitoringPermission != .granted {
|
||||
PermissionsSectionView(
|
||||
store: store,
|
||||
microphonePermission: microphonePermission,
|
||||
accessibilityPermission: accessibilityPermission,
|
||||
inputMonitoringPermission: inputMonitoringPermission
|
||||
)
|
||||
}
|
||||
|
||||
ModelSectionView(store: store, shouldFlash: store.shouldFlashModelSection)
|
||||
// Only show language picker for WhisperKit models (not Parakeet)
|
||||
if ParakeetModel(rawValue: store.hexSettings.selectedModel) == nil {
|
||||
LanguageSectionView(store: store)
|
||||
}
|
||||
|
||||
HotKeySectionView(store: store)
|
||||
|
||||
if microphonePermission == .granted {
|
||||
MicrophoneSelectionSectionView(store: store)
|
||||
}
|
||||
|
||||
SoundSectionView(store: store)
|
||||
GeneralSectionView(store: store)
|
||||
HistorySectionView(store: store)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Styles
|
||||
|
||||
extension Text {
|
||||
/// Applies caption font with secondary color, commonly used for helper/description text in settings.
|
||||
func settingsCaption() -> some View {
|
||||
self.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct SoundSectionView: View {
|
||||
@ObserveInjection var inject
|
||||
@Bindable var store: StoreOf<SettingsFeature>
|
||||
|
||||
var body: some View {
|
||||
let sliderBinding = Binding<Double>(
|
||||
get: { volumePercentage(for: store.hexSettings.soundEffectsVolume) },
|
||||
set: { store.send(.setSoundEffectsVolume(actualVolume(fromPercentage: $0))) }
|
||||
)
|
||||
|
||||
return Section {
|
||||
Label {
|
||||
Toggle(
|
||||
"Sound Effects",
|
||||
isOn: Binding(
|
||||
get: { store.hexSettings.soundEffectsEnabled },
|
||||
set: { store.send(.setSoundEffectsEnabled($0)) }
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Volume")
|
||||
Spacer()
|
||||
Text(formattedVolume(for: store.hexSettings.soundEffectsVolume))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: sliderBinding, in: 0...1)
|
||||
.disabled(!store.hexSettings.soundEffectsEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Sound")
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedVolume(for actualVolume: Double) -> String {
|
||||
let percent = volumePercentage(for: actualVolume)
|
||||
return "\(Int(round(percent * 100)))%"
|
||||
}
|
||||
|
||||
private func volumePercentage(for actualVolume: Double) -> Double {
|
||||
guard HexSettings.baseSoundEffectsVolume > 0 else { return 0 }
|
||||
let ratio = actualVolume / HexSettings.baseSoundEffectsVolume
|
||||
return max(0, min(1, ratio))
|
||||
}
|
||||
|
||||
private func actualVolume(fromPercentage percentage: Double) -> Double {
|
||||
let clampedPercentage = max(0, min(1, percentage))
|
||||
return clampedPercentage * HexSettings.baseSoundEffectsVolume
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//
|
||||
// TranscriptionFeature.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import ComposableArchitecture
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
import WhisperKit
|
||||
|
||||
private let transcriptionFeatureLogger = HexLog.transcription
|
||||
|
||||
@Reducer
|
||||
struct TranscriptionFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isRecording: Bool = false
|
||||
var isTranscribing: Bool = false
|
||||
var isPrewarming: Bool = false
|
||||
var error: String?
|
||||
var recordingStartTime: Date?
|
||||
var meter: Meter = .init(averagePower: 0, peakPower: 0)
|
||||
var sourceAppBundleID: String?
|
||||
var sourceAppName: String?
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case task
|
||||
case audioLevelUpdated(Meter)
|
||||
|
||||
// Hotkey actions
|
||||
case hotKeyPressed
|
||||
case hotKeyReleased
|
||||
|
||||
// Recording flow
|
||||
case startRecording
|
||||
case stopRecording
|
||||
|
||||
// Cancel/discard flow
|
||||
case cancel // Explicit cancellation with sound
|
||||
case discard // Silent discard (too short/accidental)
|
||||
|
||||
// Transcription result flow
|
||||
case transcriptionResult(String, URL, TimeInterval)
|
||||
case transcriptionError(Error, URL?)
|
||||
|
||||
// Model availability
|
||||
case modelMissing
|
||||
}
|
||||
|
||||
enum CancelID {
|
||||
case metering
|
||||
case recordingStart
|
||||
case recordingCleanup
|
||||
case transcription
|
||||
}
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
@Dependency(\.recording) var recording
|
||||
@Dependency(\.pasteboard) var pasteboard
|
||||
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
||||
@Dependency(\.soundEffects) var soundEffect
|
||||
@Dependency(\.sleepManagement) var sleepManagement
|
||||
@Dependency(\.date.now) var now
|
||||
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
// MARK: - Lifecycle / Setup
|
||||
|
||||
case .task:
|
||||
// Starts two concurrent effects:
|
||||
// 1) Observing audio meter
|
||||
// 2) Monitoring hot key events
|
||||
// 3) Priming the recorder for instant startup
|
||||
return .merge(
|
||||
startMeteringEffect(),
|
||||
startHotKeyMonitoringEffect(),
|
||||
warmUpRecorderEffect()
|
||||
)
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
case let .audioLevelUpdated(meter):
|
||||
state.meter = meter
|
||||
return .none
|
||||
|
||||
// MARK: - HotKey Flow
|
||||
|
||||
case .hotKeyPressed:
|
||||
// If we're transcribing, send a cancel first. Otherwise start recording immediately.
|
||||
// We'll decide later (on release) whether to keep or discard the recording.
|
||||
return handleHotKeyPressed(isTranscribing: state.isTranscribing)
|
||||
|
||||
case .hotKeyReleased:
|
||||
// If we're currently recording, then stop. Otherwise, just cancel
|
||||
// the delayed "startRecording" effect if we never actually started.
|
||||
return handleHotKeyReleased(isRecording: state.isRecording)
|
||||
|
||||
// MARK: - Recording Flow
|
||||
|
||||
case .startRecording:
|
||||
return handleStartRecording(&state)
|
||||
|
||||
case .stopRecording:
|
||||
return handleStopRecording(&state)
|
||||
|
||||
// MARK: - Transcription Results
|
||||
|
||||
case let .transcriptionResult(result, audioURL, duration):
|
||||
return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration)
|
||||
|
||||
case let .transcriptionError(error, audioURL):
|
||||
return handleTranscriptionError(&state, error: error, audioURL: audioURL)
|
||||
|
||||
case .modelMissing:
|
||||
return .none
|
||||
|
||||
// MARK: - Cancel/Discard Flow
|
||||
|
||||
case .cancel:
|
||||
// Only cancel if we're in the middle of recording, transcribing, or post-processing
|
||||
guard state.isRecording || state.isTranscribing else {
|
||||
return .none
|
||||
}
|
||||
return handleCancel(&state)
|
||||
|
||||
case .discard:
|
||||
// Silent discard for quick/accidental recordings
|
||||
guard state.isRecording else {
|
||||
return .none
|
||||
}
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Effects: Metering & HotKey
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
/// Effect to begin observing the audio meter.
|
||||
func startMeteringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
for await meter in await recording.observeAudioLevel() {
|
||||
await send(.audioLevelUpdated(meter))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.metering, cancelInFlight: true)
|
||||
}
|
||||
|
||||
/// Effect to start monitoring hotkey events through the `keyEventMonitor`.
|
||||
func startHotKeyMonitoringEffect() -> Effect<Action> {
|
||||
.run { send in
|
||||
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
|
||||
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
// Handle incoming input events (keyboard and mouse)
|
||||
let token = keyEventMonitor.handleInputEvent { inputEvent in
|
||||
// Skip if the user is currently setting a hotkey
|
||||
if isSettingHotKey {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always keep hotKeyProcessor in sync with current user hotkey preference
|
||||
hotKeyProcessor.hotkey = hexSettings.hotkey
|
||||
let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly
|
||||
hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled
|
||||
hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly
|
||||
hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime
|
||||
|
||||
switch inputEvent {
|
||||
case .keyboard(let keyEvent):
|
||||
// If Escape is pressed with no modifiers while idle, let's treat that as `cancel`.
|
||||
if keyEvent.key == .escape, keyEvent.modifiers.isEmpty,
|
||||
hotKeyProcessor.state == .idle
|
||||
{
|
||||
Task { await send(.cancel) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Process the key event
|
||||
switch hotKeyProcessor.process(keyEvent: keyEvent) {
|
||||
case .startRecording:
|
||||
Task { await send(.hotKeyPressed) }
|
||||
// If the hotkey is purely modifiers, return false to keep it from interfering with normal usage
|
||||
// But if useDoubleTapOnly is true, always intercept the key
|
||||
return useDoubleTapOnly || keyEvent.key != nil
|
||||
|
||||
case .stopRecording:
|
||||
Task { await send(.hotKeyReleased) }
|
||||
return false // or `true` if you want to intercept
|
||||
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return true
|
||||
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept - let the key chord reach other apps
|
||||
|
||||
case .none:
|
||||
// If we detect repeated same chord, maybe intercept.
|
||||
if let pressedKey = keyEvent.key,
|
||||
pressedKey == hotKeyProcessor.hotkey.key,
|
||||
keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
case .mouseClick:
|
||||
// Process mouse click - for modifier-only hotkeys, this may cancel/discard
|
||||
switch hotKeyProcessor.processMouseClick() {
|
||||
case .cancel:
|
||||
Task { await send(.cancel) }
|
||||
return false // Don't intercept the click itself
|
||||
case .discard:
|
||||
Task { await send(.discard) }
|
||||
return false // Don't intercept the click itself
|
||||
case .startRecording, .stopRecording, .none:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer { token.cancel() }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
} onCancel: {
|
||||
token.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warmUpRecorderEffect() -> Effect<Action> {
|
||||
.run { _ in
|
||||
await recording.warmUpRecorder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HotKey Press/Release Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleHotKeyPressed(isTranscribing: Bool) -> Effect<Action> {
|
||||
// If already transcribing, cancel first. Otherwise start recording immediately.
|
||||
guard isTranscribing else { return .send(.startRecording) }
|
||||
return .concatenate(
|
||||
.send(.cancel),
|
||||
.send(.startRecording)
|
||||
)
|
||||
}
|
||||
|
||||
func handleHotKeyReleased(isRecording: Bool) -> Effect<Action> {
|
||||
// Always stop recording when hotkey is released
|
||||
return isRecording ? .send(.stopRecording) : .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recording Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleStartRecording(_ state: inout State) -> Effect<Action> {
|
||||
guard state.modelBootstrapState.isModelReady else {
|
||||
return .merge(
|
||||
.send(.modelMissing),
|
||||
.run { _ in soundEffect.play(.cancel) }
|
||||
)
|
||||
}
|
||||
state.isRecording = true
|
||||
let startTime = now
|
||||
state.recordingStartTime = startTime
|
||||
|
||||
// Capture the active application
|
||||
if let activeApp = NSWorkspace.shared.frontmostApplication {
|
||||
state.sourceAppBundleID = activeApp.bundleIdentifier
|
||||
state.sourceAppName = activeApp.localizedName
|
||||
}
|
||||
transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())")
|
||||
|
||||
// Prevent system sleep during recording
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingCleanup),
|
||||
.run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in
|
||||
// Play sound immediately for instant feedback
|
||||
soundEffect.play(.startRecording)
|
||||
|
||||
if preventSleep {
|
||||
await sleepManagement.preventSleep(reason: "Hex Voice Recording")
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
if preventSleep {
|
||||
await sleepManagement.allowSleep()
|
||||
}
|
||||
return
|
||||
}
|
||||
await recording.startRecording()
|
||||
}
|
||||
.cancellable(id: CancelID.recordingStart, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleStopRecording(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
|
||||
let stopTime = now
|
||||
let startTime = state.recordingStartTime
|
||||
let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0
|
||||
|
||||
let decision = RecordingDecisionEngine.decide(
|
||||
.init(
|
||||
hotkey: state.hexSettings.hotkey,
|
||||
minimumKeyTime: state.hexSettings.minimumKeyTime,
|
||||
recordingStartTime: state.recordingStartTime,
|
||||
currentTime: stopTime
|
||||
)
|
||||
)
|
||||
|
||||
let startStamp = startTime?.ISO8601Format() ?? "nil"
|
||||
let stopStamp = stopTime.ISO8601Format()
|
||||
let minimumKeyTime = state.hexSettings.minimumKeyTime
|
||||
let hotkeyHasKey = state.hexSettings.hotkey.key != nil
|
||||
transcriptionFeatureLogger.notice(
|
||||
"Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)"
|
||||
)
|
||||
|
||||
guard decision == .proceedToTranscription else {
|
||||
// If the user recorded for less than minimumKeyTime and the hotkey is modifier-only,
|
||||
// discard the audio to avoid accidental triggers.
|
||||
transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))")
|
||||
return handleDiscard(&state)
|
||||
}
|
||||
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
// Defense-in-depth: handleStartRecording already blocks recording when the
|
||||
// bootstrap state says no model is ready, but settings can change while a
|
||||
// recording is in flight (or the in-memory bootstrap default can race a
|
||||
// cold launch). Never hand an empty model name to the transcriber: it
|
||||
// silently produces nothing (or junk like "[BLANK_AUDIO]").
|
||||
transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio")
|
||||
return .merge(
|
||||
handleDiscard(&state),
|
||||
.send(.modelMissing)
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise, proceed to transcription
|
||||
state.isTranscribing = true
|
||||
state.error = nil
|
||||
let language = state.hexSettings.outputLanguage
|
||||
|
||||
state.isPrewarming = true
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] send in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
|
||||
var audioURL: URL?
|
||||
defer {
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let stopResult = await recording.stopRecording()
|
||||
let capturedURL: URL
|
||||
switch stopResult {
|
||||
case let .captured(url):
|
||||
capturedURL = url
|
||||
case .ignored(.staleSession):
|
||||
transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session")
|
||||
return
|
||||
case .ignored(.noActiveRecording):
|
||||
transcriptionFeatureLogger.error("Recording stopped without captured audio")
|
||||
await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil))
|
||||
return
|
||||
case let .failed(error):
|
||||
transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
return
|
||||
}
|
||||
audioURL = capturedURL
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.stopRecording)
|
||||
|
||||
// Create transcription options with the selected language
|
||||
// Note: cap concurrency to avoid audio I/O overloads on some Macs
|
||||
let decodeOptions = DecodingOptions(
|
||||
language: language,
|
||||
detectLanguage: language == nil, // Only auto-detect if no language specified
|
||||
chunkingStrategy: .vad,
|
||||
)
|
||||
|
||||
let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in }
|
||||
|
||||
transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)")
|
||||
audioURL = nil
|
||||
await send(.transcriptionResult(result, capturedURL, duration))
|
||||
} catch {
|
||||
transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)")
|
||||
await send(.transcriptionError(error, nil))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transcription Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleTranscriptionResult(
|
||||
_ state: inout State,
|
||||
result: String,
|
||||
audioURL: URL,
|
||||
duration: TimeInterval
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Check for force quit command (emergency escape hatch)
|
||||
if ForceQuitCommandDetector.matches(result) {
|
||||
transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.")
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
await MainActor.run {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If empty text, nothing else to do
|
||||
guard !result.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'")
|
||||
let remappings = state.hexSettings.wordRemappings
|
||||
let removalsEnabled = state.hexSettings.wordRemovalsEnabled
|
||||
let removals = state.hexSettings.wordRemovals
|
||||
let modifiedResult: String
|
||||
if state.isRemappingScratchpadFocused {
|
||||
modifiedResult = result
|
||||
transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications")
|
||||
} else {
|
||||
var output = result
|
||||
if removalsEnabled {
|
||||
let removedResult = WordRemovalApplier.apply(output, removals: removals)
|
||||
if removedResult != output {
|
||||
let enabledRemovalCount = removals.filter(\.isEnabled).count
|
||||
transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)")
|
||||
}
|
||||
output = removedResult
|
||||
}
|
||||
let remappedResult = WordRemappingApplier.apply(output, remappings: remappings)
|
||||
if remappedResult != output {
|
||||
transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)")
|
||||
}
|
||||
let formattedResult = TranscriptFormattingApplier.apply(
|
||||
remappedResult,
|
||||
lowercase: state.hexSettings.lowercaseTranscripts,
|
||||
removePunctuation: state.hexSettings.removePunctuation
|
||||
)
|
||||
if formattedResult != remappedResult {
|
||||
transcriptionFeatureLogger.info("Applied paste formatting")
|
||||
}
|
||||
modifiedResult = formattedResult
|
||||
}
|
||||
|
||||
guard !modifiedResult.isEmpty else {
|
||||
return .run { _ in
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
}
|
||||
|
||||
let sourceAppBundleID = state.sourceAppBundleID
|
||||
let sourceAppName = state.sourceAppName
|
||||
let transcriptionHistory = state.$transcriptionHistory
|
||||
|
||||
return .run { send in
|
||||
do {
|
||||
try await finalizeRecordingAndStoreTranscript(
|
||||
result: modifiedResult,
|
||||
duration: duration,
|
||||
sourceAppBundleID: sourceAppBundleID,
|
||||
sourceAppName: sourceAppName,
|
||||
audioURL: audioURL,
|
||||
transcriptionHistory: transcriptionHistory
|
||||
)
|
||||
} catch {
|
||||
await send(.transcriptionError(error, audioURL))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.transcription)
|
||||
}
|
||||
|
||||
func handleTranscriptionError(
|
||||
_ state: inout State,
|
||||
error: Error,
|
||||
audioURL: URL?
|
||||
) -> Effect<Action> {
|
||||
state.isTranscribing = false
|
||||
state.isPrewarming = false
|
||||
state.error = error.localizedDescription
|
||||
|
||||
if let audioURL {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
/// Move file to permanent location, create a transcript record, paste text, and play sound.
|
||||
func finalizeRecordingAndStoreTranscript(
|
||||
result: String,
|
||||
duration: TimeInterval,
|
||||
sourceAppBundleID: String?,
|
||||
sourceAppName: String?,
|
||||
audioURL: URL,
|
||||
transcriptionHistory: Shared<TranscriptionHistory>
|
||||
) async throws {
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
|
||||
if hexSettings.saveTranscriptionHistory {
|
||||
let transcript = try await transcriptPersistence.save(
|
||||
result,
|
||||
audioURL,
|
||||
duration,
|
||||
sourceAppBundleID,
|
||||
sourceAppName
|
||||
)
|
||||
|
||||
transcriptionHistory.withLock { history in
|
||||
history.history.insert(transcript, at: 0)
|
||||
|
||||
if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 {
|
||||
while history.history.count > maxEntries {
|
||||
if let removedTranscript = history.history.popLast() {
|
||||
Task {
|
||||
try? await transcriptPersistence.deleteAudio(removedTranscript)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileManager.default.removeItemIfExists(at: audioURL)
|
||||
}
|
||||
|
||||
await pasteboard.paste(result)
|
||||
soundEffect.play(.pasteTranscript)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cancel/Discard Handlers
|
||||
|
||||
private extension TranscriptionFeature {
|
||||
func handleCancel(_ state: inout State) -> Effect<Action> {
|
||||
let wasRecording = state.isRecording
|
||||
state.isTranscribing = false
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
return .merge(
|
||||
.cancel(id: CancelID.transcription),
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
guard wasRecording else {
|
||||
soundEffect.play(.cancel)
|
||||
return
|
||||
}
|
||||
// Stop the recording to release microphone access
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
soundEffect.play(.cancel)
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
|
||||
func handleDiscard(_ state: inout State) -> Effect<Action> {
|
||||
state.isRecording = false
|
||||
state.isPrewarming = false
|
||||
|
||||
// Silently discard - no sound effect
|
||||
return .merge(
|
||||
.cancel(id: CancelID.recordingStart),
|
||||
.run { [sleepManagement] _ in
|
||||
// Allow system to sleep again
|
||||
await sleepManagement.allowSleep()
|
||||
let result = await recording.stopRecording()
|
||||
if case let .captured(url) = result {
|
||||
FileManager.default.removeItemIfExists(at: url)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct TranscriptionView: View {
|
||||
@Bindable var store: StoreOf<TranscriptionFeature>
|
||||
@ObserveInjection var inject
|
||||
|
||||
var status: TranscriptionIndicatorView.Status {
|
||||
if store.isTranscribing {
|
||||
return .transcribing
|
||||
} else if store.isRecording {
|
||||
return .recording
|
||||
} else if store.isPrewarming {
|
||||
return .prewarming
|
||||
} else {
|
||||
return .hidden
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TranscriptionIndicatorView(
|
||||
status: status,
|
||||
meter: store.meter
|
||||
)
|
||||
.task {
|
||||
await store.send(.task).finish()
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Force Quit Command
|
||||
|
||||
private enum ForceQuitCommandDetector {
|
||||
static func matches(_ text: String) -> Bool {
|
||||
let normalized = normalize(text)
|
||||
return normalized == "force quit hex now" || normalized == "force quit hex"
|
||||
}
|
||||
|
||||
private static func normalize(_ text: String) -> String {
|
||||
text
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// HexCapsuleView.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/25/25.
|
||||
|
||||
import AppKit
|
||||
import Inject
|
||||
import Pow
|
||||
import SwiftUI
|
||||
|
||||
struct TranscriptionIndicatorView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
enum Status {
|
||||
case hidden
|
||||
case optionKeyPressed
|
||||
case recording
|
||||
case transcribing
|
||||
case prewarming
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var meter: Meter
|
||||
|
||||
let transcribeBaseColor: Color = .blue
|
||||
private var backgroundColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording:
|
||||
return mixedColor(mixedNSColor(.red, with: .black, by: 0.5), with: .red, by: meter.averagePower * 3)
|
||||
case .transcribing: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
case .prewarming: return mixedColor(.blue, with: .black, by: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var strokeColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.black
|
||||
case .recording: return mixedColor(.red, with: .white, by: 0.1).opacity(0.6)
|
||||
case .transcribing: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
case .prewarming: return mixedColor(.blue, with: .white, by: 0.1).opacity(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
private func mixedColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> Color {
|
||||
Color(nsColor: mixedNSColor(color, with: otherColor, by: fraction))
|
||||
}
|
||||
|
||||
private func mixedNSColor(_ color: NSColor, with otherColor: NSColor, by fraction: Double) -> NSColor {
|
||||
let clampedFraction = min(max(fraction, 0), 1)
|
||||
return color.blended(withFraction: clampedFraction, of: otherColor) ?? color
|
||||
}
|
||||
|
||||
private var innerShadowColor: Color {
|
||||
switch status {
|
||||
case .hidden: return Color.clear
|
||||
case .optionKeyPressed: return Color.clear
|
||||
case .recording: return Color.red
|
||||
case .transcribing: return transcribeBaseColor
|
||||
case .prewarming: return transcribeBaseColor
|
||||
}
|
||||
}
|
||||
|
||||
private let cornerRadius: CGFloat = 8
|
||||
private let baseWidth: CGFloat = 16
|
||||
private let expandedWidth: CGFloat = 56
|
||||
|
||||
var isHidden: Bool {
|
||||
status == .hidden
|
||||
}
|
||||
|
||||
@State var transcribeEffect = 0
|
||||
|
||||
var body: some View {
|
||||
let averagePower = min(1, meter.averagePower * 3)
|
||||
let peakPower = min(1, meter.peakPower * 3)
|
||||
ZStack {
|
||||
Capsule()
|
||||
.fill(backgroundColor.shadow(.inner(color: innerShadowColor, radius: 4)))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(strokeColor, lineWidth: 1)
|
||||
.blendMode(.screen)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 1) : 0))
|
||||
.blur(radius: 2)
|
||||
.blendMode(.screen)
|
||||
.padding(6)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.white.opacity(status == .recording ? (averagePower < 0.1 ? averagePower / 0.1 : 0.5) : 0))
|
||||
.blur(radius: 1)
|
||||
.blendMode(.screen)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(7)
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
GeometryReader { proxy in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.red.opacity(status == .recording ? (peakPower < 0.1 ? (peakPower / 0.1) * 0.5 : 0.5) : 0))
|
||||
.frame(width: max(proxy.size.width * (peakPower + 0.6), 0), height: proxy.size.height, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.blur(radius: 4)
|
||||
.blendMode(.screen)
|
||||
}.padding(6)
|
||||
}
|
||||
.cornerRadius(cornerRadius)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower) : .red.opacity(0),
|
||||
radius: 4
|
||||
)
|
||||
.shadow(
|
||||
color: status == .recording ? .red.opacity(averagePower * 0.5) : .red.opacity(0),
|
||||
radius: 8
|
||||
)
|
||||
.animation(.interactiveSpring(), value: meter)
|
||||
.frame(
|
||||
width: status == .recording ? expandedWidth : baseWidth,
|
||||
height: baseWidth
|
||||
)
|
||||
.opacity(status == .hidden ? 0 : 1)
|
||||
.scaleEffect(status == .hidden ? 0.0 : 1)
|
||||
.blur(radius: status == .hidden ? 4 : 0)
|
||||
.animation(.bouncy(duration: 0.3), value: status)
|
||||
.changeEffect(.glow(color: .red.opacity(0.5), radius: 8), value: status)
|
||||
.changeEffect(.shine(angle: .degrees(0), duration: 0.6), value: transcribeEffect)
|
||||
.compositingGroup()
|
||||
.task(id: status == .transcribing) {
|
||||
while status == .transcribing, !Task.isCancelled {
|
||||
transcribeEffect += 1
|
||||
try? await Task.sleep(for: .seconds(0.25))
|
||||
}
|
||||
}
|
||||
|
||||
// Show tooltip when prewarming
|
||||
if status == .prewarming {
|
||||
VStack(spacing: 4) {
|
||||
Text("Model prewarming...")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.black.opacity(0.8))
|
||||
)
|
||||
}
|
||||
.offset(y: -24)
|
||||
.transition(.opacity)
|
||||
.zIndex(2)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("HEX") {
|
||||
VStack(spacing: 8) {
|
||||
TranscriptionIndicatorView(status: .hidden, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .optionKeyPressed, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .recording, meter: .init(averagePower: 0.5, peakPower: 0.5))
|
||||
TranscriptionIndicatorView(status: .transcribing, meter: .init(averagePower: 0, peakPower: 0))
|
||||
TranscriptionIndicatorView(status: .prewarming, meter: .init(averagePower: 0, peakPower: 0))
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
<?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>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
<?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>AudioHardwarePowerHint</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.8.4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>91</string>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>Hex needs accessibility access to monitor keyboard events for hotkey detection.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSInputMonitoringUsageDescription</key>
|
||||
<string>Hex needs Input Monitoring permission so your global hotkey works in every app.</string>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://hex-updates.s3.amazonaws.com/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>mIek27lttJe8cIBqVZFhh6reRKjpTx1h9ZY9OKWPtuM=</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+233
@@ -0,0 +1,233 @@
|
||||
[
|
||||
{
|
||||
"name": "Auto"
|
||||
},
|
||||
{
|
||||
"code": "af",
|
||||
"name": "Afrikaans"
|
||||
},
|
||||
{
|
||||
"code": "ar",
|
||||
"name": "Arabic"
|
||||
},
|
||||
{
|
||||
"code": "hy",
|
||||
"name": "Armenian"
|
||||
},
|
||||
{
|
||||
"code": "az",
|
||||
"name": "Azerbaijani"
|
||||
},
|
||||
{
|
||||
"code": "be",
|
||||
"name": "Belarusian"
|
||||
},
|
||||
{
|
||||
"code": "bs",
|
||||
"name": "Bosnian"
|
||||
},
|
||||
{
|
||||
"code": "bg",
|
||||
"name": "Bulgarian"
|
||||
},
|
||||
{
|
||||
"code": "ca",
|
||||
"name": "Catalan"
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"name": "Chinese"
|
||||
},
|
||||
{
|
||||
"code": "hr",
|
||||
"name": "Croatian"
|
||||
},
|
||||
{
|
||||
"code": "cs",
|
||||
"name": "Czech"
|
||||
},
|
||||
{
|
||||
"code": "da",
|
||||
"name": "Danish"
|
||||
},
|
||||
{
|
||||
"code": "nl",
|
||||
"name": "Dutch"
|
||||
},
|
||||
{
|
||||
"code": "en",
|
||||
"name": "English"
|
||||
},
|
||||
{
|
||||
"code": "et",
|
||||
"name": "Estonian"
|
||||
},
|
||||
{
|
||||
"code": "fi",
|
||||
"name": "Finnish"
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"name": "French"
|
||||
},
|
||||
{
|
||||
"code": "gl",
|
||||
"name": "Galician"
|
||||
},
|
||||
{
|
||||
"code": "de",
|
||||
"name": "German"
|
||||
},
|
||||
{
|
||||
"code": "el",
|
||||
"name": "Greek"
|
||||
},
|
||||
{
|
||||
"code": "he",
|
||||
"name": "Hebrew"
|
||||
},
|
||||
{
|
||||
"code": "hi",
|
||||
"name": "Hindi"
|
||||
},
|
||||
{
|
||||
"code": "hu",
|
||||
"name": "Hungarian"
|
||||
},
|
||||
{
|
||||
"code": "is",
|
||||
"name": "Icelandic"
|
||||
},
|
||||
{
|
||||
"code": "id",
|
||||
"name": "Indonesian"
|
||||
},
|
||||
{
|
||||
"code": "it",
|
||||
"name": "Italian"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"name": "Japanese"
|
||||
},
|
||||
{
|
||||
"code": "kn",
|
||||
"name": "Kannada"
|
||||
},
|
||||
{
|
||||
"code": "kk",
|
||||
"name": "Kazakh"
|
||||
},
|
||||
{
|
||||
"code": "ko",
|
||||
"name": "Korean"
|
||||
},
|
||||
{
|
||||
"code": "lv",
|
||||
"name": "Latvian"
|
||||
},
|
||||
{
|
||||
"code": "lt",
|
||||
"name": "Lithuanian"
|
||||
},
|
||||
{
|
||||
"code": "mk",
|
||||
"name": "Macedonian"
|
||||
},
|
||||
{
|
||||
"code": "ms",
|
||||
"name": "Malay"
|
||||
},
|
||||
{
|
||||
"code": "mr",
|
||||
"name": "Marathi"
|
||||
},
|
||||
{
|
||||
"code": "mi",
|
||||
"name": "Maori"
|
||||
},
|
||||
{
|
||||
"code": "ne",
|
||||
"name": "Nepali"
|
||||
},
|
||||
{
|
||||
"code": "no",
|
||||
"name": "Norwegian"
|
||||
},
|
||||
{
|
||||
"code": "fa",
|
||||
"name": "Persian"
|
||||
},
|
||||
{
|
||||
"code": "pl",
|
||||
"name": "Polish"
|
||||
},
|
||||
{
|
||||
"code": "pt",
|
||||
"name": "Portuguese"
|
||||
},
|
||||
{
|
||||
"code": "ro",
|
||||
"name": "Romanian"
|
||||
},
|
||||
{
|
||||
"code": "ru",
|
||||
"name": "Russian"
|
||||
},
|
||||
{
|
||||
"code": "sr",
|
||||
"name": "Serbian"
|
||||
},
|
||||
{
|
||||
"code": "sk",
|
||||
"name": "Slovak"
|
||||
},
|
||||
{
|
||||
"code": "sl",
|
||||
"name": "Slovenian"
|
||||
},
|
||||
{
|
||||
"code": "es",
|
||||
"name": "Spanish"
|
||||
},
|
||||
{
|
||||
"code": "sw",
|
||||
"name": "Swahili"
|
||||
},
|
||||
{
|
||||
"code": "sv",
|
||||
"name": "Swedish"
|
||||
},
|
||||
{
|
||||
"code": "tl",
|
||||
"name": "Tagalog"
|
||||
},
|
||||
{
|
||||
"code": "ta",
|
||||
"name": "Tamil"
|
||||
},
|
||||
{
|
||||
"code": "th",
|
||||
"name": "Thai"
|
||||
},
|
||||
{
|
||||
"code": "tr",
|
||||
"name": "Turkish"
|
||||
},
|
||||
{
|
||||
"code": "uk",
|
||||
"name": "Ukrainian"
|
||||
},
|
||||
{
|
||||
"code": "ur",
|
||||
"name": "Urdu"
|
||||
},
|
||||
{
|
||||
"code": "vi",
|
||||
"name": "Vietnamese"
|
||||
},
|
||||
{
|
||||
"code": "cy",
|
||||
"name": "Welsh"
|
||||
}
|
||||
]
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"displayName": "Parakeet TDT v2",
|
||||
"internalName": "parakeet-tdt-0.6b-v2-coreml",
|
||||
"size": "English",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 5,
|
||||
"storageSize": "650MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Parakeet TDT v3",
|
||||
"internalName": "parakeet-tdt-0.6b-v3-coreml",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 5,
|
||||
"storageSize": "650MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Tiny",
|
||||
"internalName": "openai_whisper-tiny",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 2,
|
||||
"speedStars": 4,
|
||||
"storageSize": "73MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Base",
|
||||
"internalName": "openai_whisper-base",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 3,
|
||||
"speedStars": 3,
|
||||
"storageSize": "140MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Large v3 Turbo",
|
||||
"internalName": "openai_whisper-large-v3-v20240930_turbo_632MB",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 4,
|
||||
"speedStars": 4,
|
||||
"storageSize": "632MB"
|
||||
},
|
||||
{
|
||||
"displayName": "Whisper Large v3",
|
||||
"internalName": "openai_whisper-large-v3-v20240930",
|
||||
"size": "Multilingual",
|
||||
"accuracyStars": 5,
|
||||
"speedStars": 2,
|
||||
"storageSize": "1.5GB"
|
||||
}
|
||||
]
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8739929: Overhaul audio engine lifecycle: recover recordings across device/route changes instead of silently capturing nothing (#251, #252, #218, #226), rebuild the warm capture engine immediately when devices change while idle instead of leaving a stale engine running (#209), suspend the warm microphone while the screen is locked or asleep and rearm on wake, stop the sound-effects engine when idle (#209), and fix a crash on quit caused by racy audio teardown (#245)
|
||||
- 2c5d8ae: Add Whisper Large v3 Turbo (632MB) to the model library: near large-v3 accuracy at several times the speed (#185). Whisper models are now labeled by their real sizes (Tiny, Base) instead of Small/Medium.
|
||||
- 9c597cb: Prevent Hex from crashing on first launch before hotkey permissions are granted (#254)
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 224822d: Fix hotkeys dying after sleep or when macOS permission checks go stale: key events arriving at the tap now self-heal the Input Monitoring state instead of being dropped, the tap survives stale permission denials, and it is recreated on wake from sleep (#250)
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eae358b: Never silently clear the selected model when a scan misfires, and route hotkey presses to model setup instead of transcribing with no model
|
||||
- eae358b: Redesign the model library: visible Download buttons, one-click model switching, and a menu to remove downloaded models or show them in Finder
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cd971a3: Reuse existing Parakeet downloads after upgrading FluidAudio
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 276da69: Add lowercase and punctuation-removal paste transforms (#253)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 10e75b9: Simplify output transforms with clearer word rules, preview, and formatting controls.
|
||||
- 8e6e041: Improve update and changelog controls in Settings.
|
||||
- 71878b7: Defer Fast Mode capture rebuilds until audio route changes settle
|
||||
- db31b0a: Move transcription model selection into a focused model library with automatic downloads on selection.
|
||||
- c00a91d: Refresh microphones reliably, harden recording cleanup, and polish the settings overlay behavior.
|
||||
- db31b0a: Handle interrupted recording stops explicitly and ignore stale Fast Mode callbacks.
|
||||
- 2b5fb15: Recognize downloaded Parakeet models immediately after restarting Hex.
|
||||
- 8e6e041: Preserve literal dollar signs in word-remapping replacements.
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f4cecbb: Show in Finder reveals Parakeet caches at the correct path (#205)
|
||||
- f4cecbb: Stay in the menu bar when launched at login (#222)
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b5a19b4: Fix word remapping deletion crashes (#207)
|
||||
- b5a19b4: Enable Super Fast Mode by default for new users
|
||||
- b5a19b4: Restore macOS Sonoma compatibility (#215)
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5a4af9b: Fix silent recordings from multichannel input devices during calls (#204)
|
||||
- b78f049: Stop priming the sound-effects audio engine when sound effects are disabled so Hex avoids unnecessary background audio activity and sleep assertions (#200).
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7340d1e: Restore double-tap lock audio capture (#193)
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 55249a6: Keep the ends of recordings from getting clipped in super fast mode.
|
||||
- d9e40cc: Use the capture engine for normal recordings to reduce startup drift
|
||||
- d9e40cc: Keep the microphone picker visible and refresh it when audio devices change
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed69836: Suppress startup windows when Hex launches as a hidden login item (#146)
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c5d5162: Add Super Fast mode to keep the mic warm and prepend a short in-memory buffer
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c018c40: Add setting to disable double-tap lock for hands-free recording
|
||||
- 7af7cd9: Update dependencies: TCA 1.23, Sparkle 2.8, swift-dependencies 1.11
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 74893ab: Support escape sequences (\n, \t, \\) in word remappings for newlines, tabs, and literal backslashes (#140)
|
||||
|
||||
## 0.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e2000d8: Fix Icon Composer app icon not displaying (#148)
|
||||
- 75bc323: Update macOS Tahoe app icon (#145)
|
||||
|
||||
## 0.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc99650: Prepare release metadata for 0.6.6
|
||||
|
||||
## 0.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3b6c966: Improve transcript modifications layout and remove log export settings
|
||||
- 3b6c966: Add opt-in regex word removals for transcripts (#121)
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 140c205: Fix Sparkle auto-update for sandboxed app by adding required XPC entitlements and SUEnableInstallerLauncherService. Users on 0.6.3 will need to manually download this update.
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c00f79e: Reduce code duplication: add ModelPatternMatcher, FileManager helpers, settingsCaption style, notification constants, and Core Audio helper
|
||||
- 658a755: Fix silent recordings caused by device-level microphone mute - automatically detects and fixes muted input devices before recording
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4c54ce: Fix microphone priming and media pause races
|
||||
- 5217d3f: Add word remappings and remove LLM UI (#000)
|
||||
- 4d38708: Add persistent MCP config editing for Claude Code modes
|
||||
- bbd0b80: Show system default mic name in picker
|
||||
- bbd0b80: Fix Parakeet polling cleanup and organize paste flow
|
||||
- 3413d68: Rename Transformations tab to Modes
|
||||
- 4d38708: Fix microphone freezing and speech cutoff when using custom microphone. Only switch input device when actually needed, re-prime recorder after device changes, and add cleanup on app termination.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e325ad: Fix Sequoia hotkey deadlock by removing Input Monitoring guard that prevented CGEventTap creation. Tap creation triggers permission prompt naturally. Re-add 'force quit Hex now' voice escape hatch from v0.5.8 (#122 #124)
|
||||
- 7e325ad: Add missing-model callout and focus settings when transcription starts without a model
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3bf2fb0: Fix voice prefix matching with punctuation - now strips punctuation (.,;:!?) when matching prefixes
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 083513c: Add comprehensive documentation to HotKeyProcessor and extract magic numbers into named constants (HexCoreConstants)
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 471310c: Fix Input Monitoring permission enforcement for hotkey reliability
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1deda2a: Route Advanced → Export Logs through the new swift-log diagnostics file so Sequoia permission bugs (#122 #124) can be diagnosed locally without relying on macOS unified logs.
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3560bdb: Keep hotkeys alive on Sequoia and add voice force-quit plus Advanced log export (#122 #124)
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6c2f1bd: Add comprehensive permissions logging for improved debugging and log export support
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 03b81c7: Let the hotkey tap start even when Input Monitoring is missing so Sequoia users get prompts again, while keeping the accessibility watchdog (#122 #124). Add a spoken “force quit Hex now” escape hatch in case permissions clobber input.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 539b0a4: Pad sub-1.5s Parakeet recordings so FluidAudio accepts them
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1eb1d0: Restore hotkeys when Input Monitoring permission is missing (#122, #124)
|
||||
- 1ee452a: Add non-interactive changeset creation for AI agents
|
||||
- 68475f5: Fix clipboard restore timing for slow apps – increased delay from 100ms to 500ms to prevent paste failures in apps that read clipboard asynchronously
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0045f28: Fix recording chime latency by switching to AVAudioEngine with pre-loaded buffers
|
||||
- 7f6c5db: Actually request macOS Input Monitoring permission when installing the key event tap so Sequoia users can record hotkeys again (#122, #124).
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix hotkey monitoring on macOS Sequoia 15.7.1 by properly handling Input Monitoring permissions (#122, #124)
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle update delivery by regenerating appcast with correct bundle versions and updating release tooling to prevent duplicate CFBundleVersion issues
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Sparkle appcast generation by cleaning duplicate bundle versions and updating release pipeline to preserve last 3 DMGs for delta generation
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 049592c: Add support for multiple Parakeet model variants: choose between English-only (v2) or multilingual (v3) based on your transcription needs.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aca9ad5: Fix microphone access retained when recording canceled with ESC (#117)
|
||||
- 049592c: Polish paste-last-transcript hotkey UI with improved layout and clearer instructions.
|
||||
- 049592c: Improve hotkey reliability with accessibility trust monitoring and automatic recovery from tap disabled events (#89, #81, #87).
|
||||
- 049592c: Improve media pausing reliability by using MediaRemote API instead of simulated keyboard events.
|
||||
- 049592c: Fix menu bar rendering issue where items appeared as single embedded view instead of separate clickable menu items.
|
||||
- 1b9bd52: Optimize recorder startup by keeping AVAudioRecorder primed between sessions, eliminating ~500ms latency for successive recordings
|
||||
- 55fb4f8: Add a sound effects volume slider beneath the toggle so users can fine-tune feedback relative to the existing 20% baseline, keeping 100% at the legacy loudness (#000).
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e50478d: Add Parakeet TDT v3 plus the first-run model bootstrap, faster recording pipeline, and solid Fn/modifier hotkeys so the next release captures all of the recent feature work (#71, #97, #113, #89, #81, #87).
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea42b5b: Move `HexSettings` + `RecordingAudioBehavior` into HexCore and add fixtures/tests so we can migrate historic settings blobs safely before shipping new media-ducking options.
|
||||
- e50478d: Adopt Changesets for SemVer + changelog management, wire release.ts to fail without pending fragments, and sync the aggregated release notes into the bundled changelog + GitHub releases.
|
||||
- 2fbbe7a: Wait for NSPasteboard changeCount to advance before pasting so panel apps always receive the latest transcript (#69, #42).
|
||||
|
||||
All notable changes to Hex are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added NVIDIA Parakeet TDT v3 support with a redesigned model manager so you can swap between Parakeet and curated Whisper variants without juggling files (#71).
|
||||
- Added first-run model bootstrap: Hex now automatically downloads the recommended model, shows progress/cancel controls, and prevents transcription from starting until a model is ready (#97).
|
||||
- Added a global hotkey to paste the last transcript plus contextual actions to cancel or delete model downloads directly from Settings, making recovery workflows faster.
|
||||
|
||||
### Improved
|
||||
|
||||
- Model downloads now surface the failing host/domain in their error message so DNS or network issues are easier to debug (#112).
|
||||
- Recording starts ~200–700 ms faster: start sounds play immediately, media pausing runs off the main actor, and transcription errors skip the extra cancel chime for less audio clutter (#113).
|
||||
- The transcription overlay tracks the active window so UI hints stay anchored to whichever app currently has focus.
|
||||
- HexSettings now lives inside HexCore with fixture-based migration tests, giving us a single source of truth for future settings changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Printable-key hotkeys (for example `⌘+'`) can now trigger short recordings just like modifier-only chords, so quick phrases aren’t discarded anymore (#113).
|
||||
- Fn and other modifier-only hotkeys respect left/right side selection, ignore phantom arrow events, and stop firing when combined with other keys, resolving long-standing regressions (#89, #81, #87).
|
||||
- Paste reliability: Hex now waits for the clipboard write to commit before firing ⌘V, so panel apps like Alfred, Raycast, and IntelliBar always receive the latest transcript instead of the previous clipboard contents (#69, #42).
|
||||
|
||||
## 1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bump version for stable release
|
||||
|
||||
## 0.1.33
|
||||
|
||||
### Added
|
||||
|
||||
- Add copy to clipboard option
|
||||
- Add support for complete keyboard shortcuts
|
||||
- Add indication for model prewarming
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issue with Hex showing in Mission Control and Cmd+Tab
|
||||
- Improve paste behavior when text input fails
|
||||
- Rework audio pausing logic to make it more reliable
|
||||
|
||||
## 0.1.26
|
||||
|
||||
### Added
|
||||
|
||||
- Add changelog
|
||||
- Add option to set minimum record time
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct AutoDownloadBannerView: View {
|
||||
@ObserveInjection var inject
|
||||
enum Style {
|
||||
case info
|
||||
case error
|
||||
}
|
||||
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var progress: Double?
|
||||
var style: Style = .info
|
||||
|
||||
private var normalizedProgress: Double? {
|
||||
progress.map { min(max($0, 0), 1) }
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
switch style {
|
||||
case .info:
|
||||
return .accentColor
|
||||
case .error:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let normalizedProgress {
|
||||
ProgressView(value: normalizedProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(accentColor.opacity(style == .error ? 0.4 : 0.25), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.12), radius: 8, x: 0, y: 4)
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch style {
|
||||
case .info:
|
||||
return "arrow.down.circle.fill"
|
||||
case .error:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
AutoDownloadBannerView(
|
||||
title: "Preparing Parakeet",
|
||||
subtitle: "42% downloaded",
|
||||
progress: 0.42
|
||||
)
|
||||
|
||||
AutoDownloadBannerView(
|
||||
title: "Model download failed",
|
||||
subtitle: "Check your connection and retry",
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// InvisibleWindow.swift
|
||||
// Hex
|
||||
//
|
||||
// Created by Kit Langton on 1/24/25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// This allows us to render SwiftUI views anywhere on the screen, without dealing with the awkward
|
||||
/// rendering issues that come with normal MacOS windows. Essentially, we create one giant invisible
|
||||
/// window that covers the entire screen, and render our SwiftUI views into it.
|
||||
///
|
||||
/// I'm pretty sure this is what CleanShot X and other apps do to render their floating widgets.
|
||||
/// But if there's a better way to do this, I'd love to know!
|
||||
class InvisibleWindow: NSPanel {
|
||||
override var canBecomeKey: Bool { false }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
private var currentScreen: NSScreen?
|
||||
private var mouseMonitor: Any?
|
||||
|
||||
init() {
|
||||
let screen = NSScreen.main ?? NSScreen.screens[0]
|
||||
let styleMask: NSWindow.StyleMask = [.fullSizeContentView, .borderless, .utilityWindow, .nonactivatingPanel]
|
||||
|
||||
super.init(contentRect: screen.frame,
|
||||
styleMask: styleMask,
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
|
||||
level = .statusBar
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
hasShadow = false
|
||||
ignoresMouseEvents = true
|
||||
hidesOnDeactivate = false // Prevent hiding when app loses focus
|
||||
canHide = false
|
||||
collectionBehavior = [.fullScreenAuxiliary, .canJoinAllSpaces, .stationary, .ignoresCycle]
|
||||
|
||||
// Set initial frame
|
||||
updateToScreenWithMouse()
|
||||
|
||||
// Start observing screen changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(screenDidChange),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Also observe screen parameters for resolution changes
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(screenDidChange),
|
||||
name: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Monitor mouse movements to detect screen boundary crossings
|
||||
mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [weak self] _ in
|
||||
self?.checkForScreenChange()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
if let monitor = mouseMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateToScreenWithMouse() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
guard let screenWithMouse = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return }
|
||||
currentScreen = screenWithMouse
|
||||
setFrame(screenWithMouse.frame, display: true)
|
||||
}
|
||||
|
||||
private func checkForScreenChange() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
guard let newScreen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return }
|
||||
|
||||
// Only update if screen actually changed
|
||||
if newScreen !== currentScreen {
|
||||
currentScreen = newScreen
|
||||
setFrame(newScreen.frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func screenDidChange(_: Notification) {
|
||||
updateToScreenWithMouse()
|
||||
}
|
||||
}
|
||||
|
||||
extension InvisibleWindow: NSWindowDelegate {
|
||||
static func fromView<V: View>(_ view: V) -> InvisibleWindow {
|
||||
let window = InvisibleWindow()
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.delegate = window
|
||||
return window
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user