diff --git a/macos/.gitignore b/macos/.gitignore
new file mode 100644
index 0000000..e11dfec
--- /dev/null
+++ b/macos/.gitignore
@@ -0,0 +1,2 @@
+.build/
+dist/
diff --git a/macos/Info.plist b/macos/Info.plist
new file mode 100644
index 0000000..4d41612
--- /dev/null
+++ b/macos/Info.plist
@@ -0,0 +1,35 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ Transui
+ CFBundleIdentifier
+ io.github.transui.macos
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Transui
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 15.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSMicrophoneUsageDescription
+ Transui records speech to transcribe it.
+
+
diff --git a/macos/Package.swift b/macos/Package.swift
new file mode 100644
index 0000000..34bff6e
--- /dev/null
+++ b/macos/Package.swift
@@ -0,0 +1,15 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+ name: "TransuiMac",
+ platforms: [.macOS("15.0")],
+ products: [
+ .executable(name: "Transui", targets: ["Transui"])
+ ],
+ targets: [
+ .executableTarget(name: "Transui"),
+ .testTarget(name: "TransuiTests", dependencies: ["Transui"]),
+ ]
+)
diff --git a/macos/Sources/Transui/AppModel.swift b/macos/Sources/Transui/AppModel.swift
new file mode 100644
index 0000000..75f2292
--- /dev/null
+++ b/macos/Sources/Transui/AppModel.swift
@@ -0,0 +1,221 @@
+import AppKit
+import Combine
+import ServiceManagement
+
+enum RecorderState: Equatable {
+ case idle
+ case preparing
+ case recording
+ case transcribing
+ case success(String)
+ case failure(String)
+}
+
+@MainActor
+final class AppModel: ObservableObject {
+ @Published private(set) var state: RecorderState = .idle
+ @Published private(set) var level = 0.0
+ @Published private(set) var elapsed: TimeInterval = 0
+ @Published private(set) var startsAtLogin = SMAppService.mainApp.status == .enabled
+ @Published private(set) var shortcutError: String?
+
+ let settings = AppSettings()
+ private let audioRecorder = AudioRecorder()
+ private let transcriptionClient = TranscriptionClient()
+ private let hotkeyManager = HotkeyManager()
+ private lazy var overlay = OverlayPanelController(model: self)
+ private var elapsedTimer: Timer?
+ private var startedAt: Date?
+ private var cancellables = Set()
+ private var registeredShortcut: HotkeyShortcut?
+ private var restoringShortcut = false
+ private var noticeID = UUID()
+ private var recordingRequestID: UUID?
+
+ init() {
+ audioRecorder.onLevel = { [weak self] level in self?.level = level }
+ registerHotkey(settings.shortcut)
+ settings.$shortcut
+ .dropFirst()
+ .sink { [weak self] shortcut in self?.registerHotkey(shortcut) }
+ .store(in: &cancellables)
+ }
+
+ var isRecording: Bool { state == .recording || state == .preparing }
+ var isWorking: Bool { isRecording || state == .transcribing }
+
+ func toggleRecording() {
+ switch state {
+ case .recording:
+ stopAndTranscribe()
+ case .preparing:
+ cancelRecording()
+ case .idle, .success, .failure:
+ Task { await startRecording() }
+ case .transcribing:
+ break
+ }
+ }
+
+ func cancelRecording() {
+ guard isRecording else { return }
+ elapsedTimer?.invalidate()
+ elapsedTimer = nil
+ recordingRequestID = nil
+ audioRecorder.cancel()
+ state = .idle
+ overlay.hide()
+ }
+
+ func setStartAtLogin(_ enabled: Bool) {
+ do {
+ if enabled {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ let status = SMAppService.mainApp.status
+ startsAtLogin = status == .enabled
+ if enabled, status == .requiresApproval {
+ showFailure("Allow Transui in Login Items.")
+ SMAppService.openSystemSettingsLoginItems()
+ }
+ } catch {
+ startsAtLogin = SMAppService.mainApp.status == .enabled
+ if isWorking {
+ NSSound.beep()
+ } else {
+ showFailure(error.localizedDescription)
+ }
+ }
+ }
+
+ func openMicrophoneSettings() {
+ openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
+ }
+
+ func openAccessibilitySettings() {
+ openSystemSettings(
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
+ }
+
+ private func startRecording() async {
+ guard !settings.endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ showFailure("Set a transcription endpoint in Settings.")
+ return
+ }
+ state = .preparing
+ overlay.show()
+ let requestID = UUID()
+ recordingRequestID = requestID
+ do {
+ try await audioRecorder.start()
+ guard recordingRequestID == requestID else {
+ audioRecorder.cancel()
+ return
+ }
+ elapsed = 0
+ startedAt = Date()
+ elapsedTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ guard let self, let startedAt = self.startedAt else { return }
+ self.elapsed = Date().timeIntervalSince(startedAt)
+ if self.elapsed >= 300 {
+ self.stopAndTranscribe()
+ }
+ }
+ }
+ state = .recording
+ } catch {
+ recordingRequestID = nil
+ audioRecorder.cancel()
+ showFailure(error.localizedDescription)
+ }
+ }
+
+ private func stopAndTranscribe() {
+ guard let recording = audioRecorder.stop() else {
+ showFailure("No recording was captured.")
+ return
+ }
+ recordingRequestID = nil
+ elapsedTimer?.invalidate()
+ elapsedTimer = nil
+ state = .transcribing
+ Task {
+ defer { try? FileManager.default.removeItem(at: recording) }
+ do {
+ let text = try await transcriptionClient.transcribe(
+ recording: recording,
+ endpoint: settings.endpoint,
+ model: settings.model,
+ key: settings.apiKey,
+ prompt: settings.prompt
+ )
+ let result = TranscriptOutput.deliver(text, to: settings.destination)
+ switch result {
+ case .typed: showSuccess("Typed at cursor")
+ case .copied: showSuccess("Copied to clipboard")
+ case .copiedBecauseAccessibilityIsOff:
+ showSuccess("Copied. Accessibility is off")
+ }
+ } catch {
+ showFailure(error.localizedDescription)
+ }
+ }
+ }
+
+ private func registerHotkey(_ shortcut: HotkeyShortcut) {
+ if restoringShortcut {
+ restoringShortcut = false
+ return
+ }
+ if hotkeyManager.register(shortcut, action: { [weak self] in self?.toggleRecording() }) {
+ registeredShortcut = shortcut
+ shortcutError = nil
+ } else {
+ shortcutError = "That shortcut is already in use."
+ if let registeredShortcut {
+ restoringShortcut = true
+ settings.shortcut = registeredShortcut
+ }
+ }
+ }
+
+ private func showSuccess(_ message: String) {
+ state = .success(message)
+ overlay.show()
+ hideOverlayAfterDelay()
+ }
+
+ private func showFailure(_ message: String) {
+ elapsedTimer?.invalidate()
+ elapsedTimer = nil
+ state = .failure(message)
+ overlay.show()
+ hideOverlayAfterDelay(seconds: 3.5)
+ }
+
+ private func hideOverlayAfterDelay(seconds: Double = 1.6) {
+ let id = UUID()
+ noticeID = id
+ Task {
+ try? await Task.sleep(for: .seconds(seconds))
+ guard noticeID == id else { return }
+ guard case .success = state else {
+ if case .failure = state {
+ state = .idle
+ overlay.hide()
+ }
+ return
+ }
+ state = .idle
+ overlay.hide()
+ }
+ }
+
+ private func openSystemSettings(_ value: String) {
+ guard let url = URL(string: value) else { return }
+ NSWorkspace.shared.open(url)
+ }
+}
diff --git a/macos/Sources/Transui/AppSettings.swift b/macos/Sources/Transui/AppSettings.swift
new file mode 100644
index 0000000..d2a615b
--- /dev/null
+++ b/macos/Sources/Transui/AppSettings.swift
@@ -0,0 +1,66 @@
+import Combine
+import Foundation
+
+enum TranscriptDestination: String, CaseIterable, Identifiable {
+ case cursor
+ case clipboard
+
+ var id: Self { self }
+
+ var label: String {
+ switch self {
+ case .cursor: "Type at cursor"
+ case .clipboard: "Copy to clipboard"
+ }
+ }
+}
+
+@MainActor
+final class AppSettings: ObservableObject {
+ private enum Key {
+ static let endpoint = "endpoint"
+ static let model = "model"
+ static let prompt = "prompt"
+ static let destination = "destination"
+ static let shortcut = "shortcut"
+ }
+
+ private let defaults: UserDefaults
+
+ @Published var endpoint: String { didSet { defaults.set(endpoint, forKey: Key.endpoint) } }
+ @Published var model: String { didSet { defaults.set(model, forKey: Key.model) } }
+ @Published var prompt: String { didSet { defaults.set(prompt, forKey: Key.prompt) } }
+ @Published var destination: TranscriptDestination {
+ didSet { defaults.set(destination.rawValue, forKey: Key.destination) }
+ }
+ @Published var shortcut: HotkeyShortcut {
+ didSet {
+ if let data = try? JSONEncoder().encode(shortcut) {
+ defaults.set(data, forKey: Key.shortcut)
+ }
+ }
+ }
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ endpoint = defaults.string(forKey: Key.endpoint) ?? ""
+ model = defaults.string(forKey: Key.model) ?? ""
+ prompt = defaults.string(forKey: Key.prompt) ?? ""
+ destination =
+ TranscriptDestination(
+ rawValue: defaults.string(forKey: Key.destination) ?? ""
+ ) ?? .cursor
+ if let data = defaults.data(forKey: Key.shortcut),
+ let saved = try? JSONDecoder().decode(HotkeyShortcut.self, from: data)
+ {
+ shortcut = saved
+ } else {
+ shortcut = .defaultShortcut
+ }
+ }
+
+ var apiKey: String {
+ get { KeychainStore.read() }
+ set { KeychainStore.write(newValue) }
+ }
+}
diff --git a/macos/Sources/Transui/AudioRecorder.swift b/macos/Sources/Transui/AudioRecorder.swift
new file mode 100644
index 0000000..b62757d
--- /dev/null
+++ b/macos/Sources/Transui/AudioRecorder.swift
@@ -0,0 +1,86 @@
+import AVFoundation
+import Foundation
+
+enum RecordingError: LocalizedError {
+ case microphoneDenied
+ case couldNotStart
+
+ var errorDescription: String? {
+ switch self {
+ case .microphoneDenied: "Microphone access is off. Enable it in System Settings."
+ case .couldNotStart: "The microphone could not start recording."
+ }
+ }
+}
+
+@MainActor
+final class AudioRecorder: NSObject, AVAudioRecorderDelegate {
+ private var recorder: AVAudioRecorder?
+ private var meterTimer: Timer?
+ private(set) var fileURL: URL?
+ var onLevel: ((Double) -> Void)?
+
+ func start() async throws {
+ guard await Self.requestPermission() else { throw RecordingError.microphoneDenied }
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("transui-\(UUID().uuidString).wav")
+ self.fileURL = fileURL
+ let settings: [String: Any] = [
+ AVFormatIDKey: Int(kAudioFormatLinearPCM),
+ AVSampleRateKey: 16_000,
+ AVNumberOfChannelsKey: 1,
+ AVLinearPCMBitDepthKey: 16,
+ AVLinearPCMIsFloatKey: false,
+ AVLinearPCMIsBigEndianKey: false,
+ ]
+ let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
+ recorder.delegate = self
+ recorder.isMeteringEnabled = true
+ guard recorder.prepareToRecord(), recorder.record() else {
+ throw RecordingError.couldNotStart
+ }
+ self.recorder = recorder
+ meterTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.updateLevel() }
+ }
+ }
+
+ func stop() -> URL? {
+ meterTimer?.invalidate()
+ meterTimer = nil
+ recorder?.stop()
+ recorder = nil
+ onLevel?(0)
+ let url = fileURL
+ fileURL = nil
+ return url
+ }
+
+ func cancel() {
+ let url = stop()
+ if let url { try? FileManager.default.removeItem(at: url) }
+ fileURL = nil
+ }
+
+ private func updateLevel() {
+ guard let recorder else { return }
+ recorder.updateMeters()
+ let decibels = Double(recorder.averagePower(forChannel: 0))
+ let normalized = max(0, min(1, pow(10, decibels / 32)))
+ onLevel?(normalized)
+ }
+
+ private static func requestPermission() async -> Bool {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized: true
+ case .denied, .restricted: false
+ case .notDetermined:
+ await withCheckedContinuation { continuation in
+ AVCaptureDevice.requestAccess(for: .audio) { allowed in
+ continuation.resume(returning: allowed)
+ }
+ }
+ @unknown default: false
+ }
+ }
+}
diff --git a/macos/Sources/Transui/HotkeyManager.swift b/macos/Sources/Transui/HotkeyManager.swift
new file mode 100644
index 0000000..1b4f72c
--- /dev/null
+++ b/macos/Sources/Transui/HotkeyManager.swift
@@ -0,0 +1,70 @@
+import Carbon.HIToolbox
+import Foundation
+
+final class HotkeyManager {
+ private var hotkey: EventHotKeyRef?
+ private var handler: EventHandlerRef?
+ private var action: (() -> Void)?
+
+ init() {
+ var type = EventTypeSpec(
+ eventClass: OSType(kEventClassKeyboard),
+ eventKind: UInt32(kEventHotKeyPressed)
+ )
+ InstallEventHandler(
+ GetApplicationEventTarget(),
+ { _, event, context in
+ guard let event, let context else { return noErr }
+ var identifier = EventHotKeyID()
+ let status = GetEventParameter(
+ event,
+ EventParamName(kEventParamDirectObject),
+ EventParamType(typeEventHotKeyID),
+ nil,
+ MemoryLayout.size,
+ nil,
+ &identifier
+ )
+ guard status == noErr, identifier.id == 1 else { return status }
+ let manager = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ DispatchQueue.main.async { manager.action?() }
+ return noErr
+ },
+ 1,
+ &type,
+ Unmanaged.passUnretained(self).toOpaque(),
+ &handler
+ )
+ }
+
+ deinit {
+ unregister()
+ if let handler { RemoveEventHandler(handler) }
+ }
+
+ @discardableResult
+ func register(_ shortcut: HotkeyShortcut, action: @escaping () -> Void) -> Bool {
+ let identifier = EventHotKeyID(signature: Self.signature, id: 1)
+ var candidate: EventHotKeyRef?
+ let status = RegisterEventHotKey(
+ shortcut.keyCode,
+ shortcut.modifiers,
+ identifier,
+ GetApplicationEventTarget(),
+ 0,
+ &candidate
+ )
+ guard status == noErr, let candidate else { return false }
+ unregister()
+ hotkey = candidate
+ self.action = action
+ return true
+ }
+
+ private func unregister() {
+ if let hotkey { UnregisterEventHotKey(hotkey) }
+ hotkey = nil
+ }
+
+ private static let signature: OSType = 0x5452_5549 // TRUI
+}
diff --git a/macos/Sources/Transui/HotkeyRecorder.swift b/macos/Sources/Transui/HotkeyRecorder.swift
new file mode 100644
index 0000000..1103618
--- /dev/null
+++ b/macos/Sources/Transui/HotkeyRecorder.swift
@@ -0,0 +1,59 @@
+import AppKit
+import SwiftUI
+
+struct HotkeyRecorder: NSViewRepresentable {
+ @Binding var shortcut: HotkeyShortcut
+
+ func makeNSView(context: Context) -> HotkeyCaptureView {
+ let view = HotkeyCaptureView()
+ view.onShortcut = { shortcut = $0 }
+ view.stringValue = shortcut.displayName
+ return view
+ }
+
+ func updateNSView(_ view: HotkeyCaptureView, context: Context) {
+ view.onShortcut = { shortcut = $0 }
+ if view.window?.firstResponder !== view {
+ view.stringValue = shortcut.displayName
+ }
+ }
+}
+
+final class HotkeyCaptureView: NSTextField {
+ var onShortcut: ((HotkeyShortcut) -> Void)?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ isEditable = false
+ isSelectable = false
+ isBezeled = true
+ bezelStyle = .roundedBezel
+ alignment = .center
+ font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .medium)
+ toolTip = "Click, then press a shortcut"
+ }
+
+ required init?(coder: NSCoder) { nil }
+
+ override var acceptsFirstResponder: Bool { true }
+
+ override func mouseDown(with event: NSEvent) {
+ window?.makeFirstResponder(self)
+ stringValue = "Type shortcut"
+ }
+
+ override func keyDown(with event: NSEvent) {
+ guard let shortcut = HotkeyShortcut(event: event) else {
+ NSSound.beep()
+ return
+ }
+ stringValue = shortcut.displayName
+ onShortcut?(shortcut)
+ window?.makeFirstResponder(nil)
+ }
+
+ override func resignFirstResponder() -> Bool {
+ needsDisplay = true
+ return super.resignFirstResponder()
+ }
+}
diff --git a/macos/Sources/Transui/HotkeyShortcut.swift b/macos/Sources/Transui/HotkeyShortcut.swift
new file mode 100644
index 0000000..a5d6a26
--- /dev/null
+++ b/macos/Sources/Transui/HotkeyShortcut.swift
@@ -0,0 +1,67 @@
+import AppKit
+import Carbon.HIToolbox
+
+struct HotkeyShortcut: Codable, Equatable {
+ let keyCode: UInt32
+ let modifiers: UInt32
+
+ static let defaultShortcut = HotkeyShortcut(
+ keyCode: UInt32(kVK_ANSI_S),
+ modifiers: UInt32(cmdKey | optionKey)
+ )
+
+ var displayName: String {
+ var result = ""
+ if modifiers & UInt32(controlKey) != 0 { result += "⌃" }
+ if modifiers & UInt32(optionKey) != 0 { result += "⌥" }
+ if modifiers & UInt32(shiftKey) != 0 { result += "⇧" }
+ if modifiers & UInt32(cmdKey) != 0 { result += "⌘" }
+ result += Self.keyNames[keyCode] ?? "Key \(keyCode)"
+ return result
+ }
+
+ init(keyCode: UInt32, modifiers: UInt32) {
+ self.keyCode = keyCode
+ self.modifiers = modifiers
+ }
+
+ init?(event: NSEvent) {
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ var carbonModifiers: UInt32 = 0
+ if flags.contains(.command) { carbonModifiers |= UInt32(cmdKey) }
+ if flags.contains(.option) { carbonModifiers |= UInt32(optionKey) }
+ if flags.contains(.control) { carbonModifiers |= UInt32(controlKey) }
+ if flags.contains(.shift) { carbonModifiers |= UInt32(shiftKey) }
+ guard carbonModifiers != 0 else { return nil }
+ self.init(keyCode: UInt32(event.keyCode), modifiers: carbonModifiers)
+ }
+
+ private static let keyNames: [UInt32: String] = [
+ UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B",
+ UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D",
+ UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
+ UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H",
+ UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J",
+ UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
+ UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N",
+ UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P",
+ UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
+ UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T",
+ UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V",
+ UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
+ UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
+ UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1",
+ UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
+ UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5",
+ UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7",
+ UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
+ UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return",
+ UInt32(kVK_Tab): "Tab", UInt32(kVK_Escape): "Esc",
+ UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2",
+ UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
+ UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6",
+ UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
+ UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10",
+ UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12",
+ ]
+}
diff --git a/macos/Sources/Transui/KeychainStore.swift b/macos/Sources/Transui/KeychainStore.swift
new file mode 100644
index 0000000..0092b73
--- /dev/null
+++ b/macos/Sources/Transui/KeychainStore.swift
@@ -0,0 +1,41 @@
+import Foundation
+import Security
+
+enum KeychainStore {
+ private static let service = "io.github.transui.macos"
+ private static let account = "remote-api-key"
+
+ static func read() -> String {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data
+ else { return "" }
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ static func write(_ value: String) {
+ let identity: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ if value.isEmpty {
+ SecItemDelete(identity as CFDictionary)
+ return
+ }
+ let data = Data(value.utf8)
+ let attributes = [kSecValueData as String: data]
+ if SecItemUpdate(identity as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
+ var item = identity
+ item[kSecValueData as String] = data
+ SecItemAdd(item as CFDictionary, nil)
+ }
+ }
+}
diff --git a/macos/Sources/Transui/OverlayPanel.swift b/macos/Sources/Transui/OverlayPanel.swift
new file mode 100644
index 0000000..e712d40
--- /dev/null
+++ b/macos/Sources/Transui/OverlayPanel.swift
@@ -0,0 +1,133 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class OverlayPanelController {
+ private let panel: NSPanel
+
+ init(model: AppModel) {
+ panel = NSPanel(
+ contentRect: NSRect(x: 0, y: 0, width: 304, height: 82),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.level = .floating
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = true
+ panel.hidesOnDeactivate = false
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
+ panel.contentView = NSHostingView(rootView: RecordingOverlay(model: model))
+ }
+
+ func show() {
+ positionPanel()
+ panel.orderFrontRegardless()
+ }
+
+ func hide() {
+ panel.orderOut(nil)
+ }
+
+ private func positionPanel() {
+ let mouse = NSEvent.mouseLocation
+ let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main
+ guard let frame = screen?.visibleFrame else { return }
+ let x = frame.midX - panel.frame.width / 2
+ let y = frame.minY + 42
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
+ }
+}
+
+private struct RecordingOverlay: View {
+ @ObservedObject var model: AppModel
+ private let teal = Color(red: 0.039, green: 0.624, blue: 0.604)
+
+ var body: some View {
+ HStack(spacing: 14) {
+ signal
+ .frame(width: 46, height: 34)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ Text(detail)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 6)
+ if model.isRecording {
+ Button(action: model.toggleRecording) {
+ Image(systemName: "stop.fill")
+ .font(.system(size: 11, weight: .bold))
+ .frame(width: 28, height: 28)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(teal)
+ .clipShape(Circle())
+ .help("Stop and transcribe")
+ }
+ }
+ .padding(.horizontal, 17)
+ .frame(width: 304, height: 82)
+ .background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 19, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 19, style: .continuous)
+ .strokeBorder(.white.opacity(0.13))
+ }
+ }
+
+ @ViewBuilder
+ private var signal: some View {
+ switch model.state {
+ case .recording:
+ HStack(alignment: .center, spacing: 3) {
+ ForEach(0..<5, id: \.self) { index in
+ Capsule()
+ .fill(teal)
+ .frame(width: 4, height: barHeight(index))
+ }
+ }
+ case .preparing, .transcribing:
+ ProgressView().controlSize(.small).tint(teal)
+ case .success:
+ Image(systemName: "checkmark.circle.fill").font(.title2).foregroundStyle(teal)
+ case .failure:
+ Image(systemName: "exclamationmark.triangle.fill").font(.title3).foregroundStyle(.red)
+ case .idle:
+ Image(systemName: "waveform").foregroundStyle(teal)
+ }
+ }
+
+ private var title: String {
+ switch model.state {
+ case .preparing: "Starting microphone"
+ case .recording: "Recording"
+ case .transcribing: "Transcribing"
+ case .success(let message), .failure(let message): message
+ case .idle: "Ready"
+ }
+ }
+
+ private var detail: String {
+ switch model.state {
+ case .recording: Self.duration(model.elapsed)
+ case .preparing: "Waiting for permission"
+ case .transcribing: "Sending audio"
+ case .failure: "Open Transui settings to fix this"
+ case .success: "Done"
+ case .idle: model.settings.shortcut.displayName
+ }
+ }
+
+ private func barHeight(_ index: Int) -> CGFloat {
+ let shape = [0.55, 0.82, 1.0, 0.72, 0.45][index]
+ return 7 + 24 * max(0.08, model.level * shape)
+ }
+
+ private static func duration(_ interval: TimeInterval) -> String {
+ let seconds = Int(interval)
+ return String(format: "%02d:%02d", seconds / 60, seconds % 60)
+ }
+}
diff --git a/macos/Sources/Transui/SettingsView.swift b/macos/Sources/Transui/SettingsView.swift
new file mode 100644
index 0000000..af1faf0
--- /dev/null
+++ b/macos/Sources/Transui/SettingsView.swift
@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject var model: AppModel
+ @ObservedObject private var settings: AppSettings
+ @State private var apiKey = ""
+
+ init(model: AppModel) {
+ self.model = model
+ settings = model.settings
+ }
+
+ var body: some View {
+ Form {
+ Section("Transcription") {
+ TextField("Endpoint", text: $settings.endpoint, prompt: Text("https://api.example.com"))
+ TextField("Model", text: $settings.model)
+ SecureField("API key", text: $apiKey)
+ .onChange(of: apiKey) { _, value in settings.apiKey = value }
+ TextField("Prompt", text: $settings.prompt, axis: .vertical)
+ .lineLimit(2...4)
+ Picker("Output", selection: $settings.destination) {
+ ForEach(TranscriptDestination.allCases) { destination in
+ Text(destination.label).tag(destination)
+ }
+ }
+ }
+
+ Section("Controls") {
+ LabeledContent("Record shortcut") {
+ HotkeyRecorder(shortcut: $settings.shortcut)
+ .frame(width: 132, height: 24)
+ }
+ if let shortcutError = model.shortcutError {
+ Text(shortcutError)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ Toggle(
+ "Start at login",
+ isOn: Binding(
+ get: { model.startsAtLogin },
+ set: model.setStartAtLogin
+ )
+ )
+ }
+
+ Section("Permissions") {
+ LabeledContent("Microphone") {
+ Button("Open settings", action: model.openMicrophoneSettings)
+ }
+ LabeledContent("Type at cursor") {
+ Button("Open Accessibility", action: model.openAccessibilitySettings)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 500, height: 470)
+ .onAppear { apiKey = settings.apiKey }
+ }
+}
diff --git a/macos/Sources/Transui/TranscriptCleaner.swift b/macos/Sources/Transui/TranscriptCleaner.swift
new file mode 100644
index 0000000..7e68a52
--- /dev/null
+++ b/macos/Sources/Transui/TranscriptCleaner.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+enum TranscriptCleaner {
+ static func clean(_ text: String) -> String {
+ var prefix = text.startIndex
+ if prefix < text.endIndex, text[prefix] == "\u{FEFF}" {
+ prefix = text.index(after: prefix)
+ }
+ while prefix < text.endIndex, text[prefix].isWhitespace {
+ prefix = text.index(after: prefix)
+ }
+ guard text[prefix...].hasPrefix("language ") else { return text }
+
+ let markers = ["", ""]
+ let marker = markers.compactMap { text.range(of: $0, range: prefix.. OutputResult {
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(text, forType: .string)
+ guard destination == .cursor else { return .copied }
+
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
+ guard AXIsProcessTrustedWithOptions(options as CFDictionary) else {
+ return .copiedBecauseAccessibilityIsOff
+ }
+ guard let source = CGEventSource(stateID: .hidSystemState),
+ let keyDown = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: CGKeyCode(kVK_ANSI_V),
+ keyDown: true
+ ),
+ let keyUp = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: CGKeyCode(kVK_ANSI_V),
+ keyDown: false
+ )
+ else {
+ return .copiedBecauseAccessibilityIsOff
+ }
+ keyDown.flags = .maskCommand
+ keyUp.flags = .maskCommand
+ keyDown.post(tap: .cghidEventTap)
+ keyUp.post(tap: .cghidEventTap)
+ return .typed
+ }
+}
diff --git a/macos/Sources/Transui/TranscriptionClient.swift b/macos/Sources/Transui/TranscriptionClient.swift
new file mode 100644
index 0000000..db13330
--- /dev/null
+++ b/macos/Sources/Transui/TranscriptionClient.swift
@@ -0,0 +1,110 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+ import FoundationNetworking
+#endif
+
+enum TranscriptionError: LocalizedError {
+ case invalidEndpoint
+ case invalidResponse
+ case http(Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidEndpoint: "The transcription endpoint is invalid."
+ case .invalidResponse: "The server response did not contain a transcript."
+ case .http(let status): "The server returned HTTP \(status)."
+ }
+ }
+}
+
+struct TranscriptionClient {
+ static func endpointURL(from value: String) -> URL? {
+ var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ while endpoint.hasSuffix("/") { endpoint.removeLast() }
+ if !endpoint.contains("/v1/audio/transcriptions") {
+ endpoint += "/v1/audio/transcriptions"
+ }
+ guard let url = URL(string: endpoint),
+ let scheme = url.scheme?.lowercased(),
+ scheme == "http" || scheme == "https",
+ url.host != nil
+ else { return nil }
+ return url
+ }
+
+ func transcribe(
+ recording: URL,
+ endpoint: String,
+ model: String,
+ key: String,
+ prompt: String
+ ) async throws -> String {
+ guard let url = Self.endpointURL(from: endpoint) else {
+ throw TranscriptionError.invalidEndpoint
+ }
+
+ let boundary = "transui-\(UUID().uuidString)"
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 300
+ request.setValue(
+ "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if !key.isEmpty {
+ request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+ }
+ request.httpBody = try multipartBody(
+ boundary: boundary,
+ recording: recording,
+ model: model,
+ prompt: prompt
+ )
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw TranscriptionError.invalidResponse
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw TranscriptionError.http(http.statusCode)
+ }
+ guard data.count <= 1_048_576 else {
+ throw TranscriptionError.invalidResponse
+ }
+ guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let text = object["text"] as? String
+ else {
+ throw TranscriptionError.invalidResponse
+ }
+ return TranscriptCleaner.clean(text)
+ }
+
+ private func multipartBody(
+ boundary: String,
+ recording: URL,
+ model: String,
+ prompt: String
+ ) throws -> Data {
+ var data = Data()
+ func field(_ name: String, _ value: String) {
+ data.append("--\(boundary)\r\n")
+ data.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
+ data.append("\(value)\r\n")
+ }
+ field("response_format", "json")
+ if !model.isEmpty { field("model", model) }
+ if !prompt.isEmpty { field("prompt", prompt) }
+ data.append("--\(boundary)\r\n")
+ data.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n")
+ data.append("Content-Type: audio/wav\r\n\r\n")
+ data.append(try Data(contentsOf: recording))
+ data.append("\r\n--\(boundary)--\r\n")
+ return data
+ }
+}
+
+extension Data {
+ fileprivate mutating func append(_ string: String) {
+ append(contentsOf: string.utf8)
+ }
+}
diff --git a/macos/Sources/Transui/TransuiApp.swift b/macos/Sources/Transui/TransuiApp.swift
new file mode 100644
index 0000000..f6c1d90
--- /dev/null
+++ b/macos/Sources/Transui/TransuiApp.swift
@@ -0,0 +1,44 @@
+import AppKit
+import SwiftUI
+
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.accessory)
+ }
+}
+
+@main
+struct TransuiApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @StateObject private var model = AppModel()
+
+ var body: some Scene {
+ MenuBarExtra {
+ Button(model.isRecording ? "Stop and transcribe" : "Start recording") {
+ model.toggleRecording()
+ }
+ .keyboardShortcut("r")
+ .disabled(model.state == .transcribing)
+
+ if model.isRecording {
+ Button("Cancel recording", role: .destructive) {
+ model.cancelRecording()
+ }
+ }
+
+ Divider()
+ SettingsLink { Text("Settings…") }
+ Divider()
+ Button("Quit Transui") { NSApp.terminate(nil) }
+ .keyboardShortcut("q")
+ } label: {
+ Image(systemName: model.isRecording ? "waveform.circle.fill" : "waveform")
+ .accessibilityLabel(model.isRecording ? "Transui recording" : "Transui")
+ }
+
+ Settings {
+ SettingsView(model: model)
+ }
+ .windowResizability(.contentSize)
+ }
+}
diff --git a/macos/Tests/TransuiTests/TranscriptCleanerTests.swift b/macos/Tests/TransuiTests/TranscriptCleanerTests.swift
new file mode 100644
index 0000000..e8e5a81
--- /dev/null
+++ b/macos/Tests/TransuiTests/TranscriptCleanerTests.swift
@@ -0,0 +1,30 @@
+import XCTest
+
+@testable import Transui
+
+final class TranscriptCleanerTests: XCTestCase {
+ func testRemovesAsTextPrefix() {
+ XCTAssertEqual(
+ TranscriptCleaner.clean("language EnglishHello there"),
+ "Hello there"
+ )
+ }
+
+ func testRemovesAsrTextPrefix() {
+ XCTAssertEqual(
+ TranscriptCleaner.clean("language zh 你好"),
+ "你好"
+ )
+ }
+
+ func testRemovesPrefixAfterWhitespaceAndBom() {
+ XCTAssertEqual(
+ TranscriptCleaner.clean("\u{FEFF} \n\tlanguage EnglishHello"),
+ "Hello"
+ )
+ }
+
+ func testPreservesOrdinaryTranscript() {
+ XCTAssertEqual(TranscriptCleaner.clean("language is useful"), "language is useful")
+ }
+}
diff --git a/macos/Tests/TransuiTests/TranscriptionClientTests.swift b/macos/Tests/TransuiTests/TranscriptionClientTests.swift
new file mode 100644
index 0000000..8d20ccf
--- /dev/null
+++ b/macos/Tests/TransuiTests/TranscriptionClientTests.swift
@@ -0,0 +1,26 @@
+import XCTest
+
+@testable import Transui
+
+final class TranscriptionClientTests: XCTestCase {
+ func testAddsTranscriptionPath() {
+ XCTAssertEqual(
+ TranscriptionClient.endpointURL(from: "https://example.com/")?.absoluteString,
+ "https://example.com/v1/audio/transcriptions"
+ )
+ }
+
+ func testKeepsFullTranscriptionPath() {
+ XCTAssertEqual(
+ TranscriptionClient.endpointURL(
+ from: "https://example.com/v1/audio/transcriptions"
+ )?.absoluteString,
+ "https://example.com/v1/audio/transcriptions"
+ )
+ }
+
+ func testRejectsInvalidEndpoint() {
+ XCTAssertNil(TranscriptionClient.endpointURL(from: ""))
+ XCTAssertNil(TranscriptionClient.endpointURL(from: "example.com"))
+ }
+}