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")
|
||||
}
|
||||
Reference in New Issue
Block a user