From 32e7f32e0a2390d64026186bec08e7611c5a770a Mon Sep 17 00:00:00 2001 From: jourintown <2+jourintown@noreply.localhost> Date: Sat, 29 Aug 2026 20:30:30 +0200 Subject: [PATCH] Feat macos (#2) --------- Co-authored-by: intelligence Co-authored-by: jourintown Reviewed-on: https://git.jourin.town/jourintown/transui/pulls/2 --- .gitignore | 1 + AGENTS.md | 6 +- README.md | 26 +- macos/.gitignore | 2 + macos/Info.plist | 35 +++ macos/Sources/Transui/AppModel.swift | 237 ++++++++++++++++++ macos/Sources/Transui/AppSettings.swift | 90 +++++++ macos/Sources/Transui/AudioRecorder.swift | 86 +++++++ macos/Sources/Transui/HotkeyManager.swift | 70 ++++++ macos/Sources/Transui/HotkeyRecorder.swift | 59 +++++ macos/Sources/Transui/HotkeyShortcut.swift | 67 +++++ macos/Sources/Transui/KeychainStore.swift | 45 ++++ macos/Sources/Transui/OverlayPanel.swift | 133 ++++++++++ macos/Sources/Transui/SettingsView.swift | 77 ++++++ macos/Sources/Transui/TranscriptCleaner.swift | 20 ++ macos/Sources/Transui/TranscriptOutput.swift | 43 ++++ .../Sources/Transui/TranscriptionClient.swift | 161 ++++++++++++ macos/Sources/Transui/TransuiApp.swift | 44 ++++ .../TransuiTests/TranscriptCleanerTests.swift | 30 +++ .../TranscriptionClientTests.swift | 60 +++++ macos/Transui.entitlements | 8 + macos/project.yml | 67 +++++ 22 files changed, 1360 insertions(+), 7 deletions(-) create mode 100644 macos/.gitignore create mode 100644 macos/Info.plist create mode 100644 macos/Sources/Transui/AppModel.swift create mode 100644 macos/Sources/Transui/AppSettings.swift create mode 100644 macos/Sources/Transui/AudioRecorder.swift create mode 100644 macos/Sources/Transui/HotkeyManager.swift create mode 100644 macos/Sources/Transui/HotkeyRecorder.swift create mode 100644 macos/Sources/Transui/HotkeyShortcut.swift create mode 100644 macos/Sources/Transui/KeychainStore.swift create mode 100644 macos/Sources/Transui/OverlayPanel.swift create mode 100644 macos/Sources/Transui/SettingsView.swift create mode 100644 macos/Sources/Transui/TranscriptCleaner.swift create mode 100644 macos/Sources/Transui/TranscriptOutput.swift create mode 100644 macos/Sources/Transui/TranscriptionClient.swift create mode 100644 macos/Sources/Transui/TransuiApp.swift create mode 100644 macos/Tests/TransuiTests/TranscriptCleanerTests.swift create mode 100644 macos/Tests/TransuiTests/TranscriptionClientTests.swift create mode 100644 macos/Transui.entitlements create mode 100644 macos/project.yml diff --git a/.gitignore b/.gitignore index 4b46828..070043d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /android/build/ /android/local.properties /android/app/build/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 030f64c..3b7a8f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,15 @@ # Transui design - Linux is a GTK3 desktop recorder. It supports hosted transcription with an optional local fallback and can type at the cursor or copy to the clipboard. +- macOS is a SwiftUI menu bar recorder targeting macOS 15. It supports the same hosted-first, local-fallback behavior as Linux, a global recording hotkey, launch at login, cursor typing, and clipboard output. When hosted-first is off, use local only. Its compact floating recorder uses teal level bars for recording feedback. - Android is remote-only. It provides an Android `RecognitionService`, a voice-only input method, and a floating recognition activity. Do not add a local llama.cpp path to Android. -- Both clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …` or `language …` prefix. +- All clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …` or `language …` prefix. +- Generate the macOS Xcode project from `macos/project.yml` with XcodeGen. Treat the spec as the source of truth and leave `macos/Transui.xcodeproj` untracked. - The Android voice UI is an opaque, compact system surface. Teal is the recording signal. `PushToTalkButton` draws the Apps and Stop Circle glyphs with Canvas because the target device drops `ImageButton` image layers. Keep the glyphs drawn above the button background. ## llama.cpp local endpoint -The Linux local fallback begins with this multipart request. `response_format=json` +The Linux and macOS local fallback uses this multipart request. `response_format=json` is required; `prompt` is optional and may request transcription or translation. ```sh diff --git a/README.md b/README.md index 850528e..8dc3721 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Transui -A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp -`/v1/audio/transcriptions` endpoints. It also includes a remote-only Android -speech-recognition provider and voice input method. +A compact transcription tool for Linux, macOS, and Android. Linux and macOS +support OpenAI-compatible hosted endpoints with an optional llama.cpp local +fallback. Android provides remote-only speech recognition and voice input. ## Dependencies @@ -14,16 +14,32 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ libjson-glib-dev wtype xdotool ``` +On macOS, install Xcode 16 and XcodeGen. + ## Build +### Linux + ```sh cmake -S . -B build cmake --build build sudo cmake --install build ``` -Android release APKs build in Gitea CI. To run local Android checks, configure -the Android SDK and run: +### macOS + +```sh +cd macos +xcodegen generate +open Transui.xcodeproj +``` + +Build the `Transui` scheme in Xcode. + +### Android + +Android release APKs build in Gitea CI. To run local checks, configure the +Android SDK and run: ```sh cd android diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..f68871f --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,2 @@ +DerivedData/ +Transui.xcodeproj/ 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/Sources/Transui/AppModel.swift b/macos/Sources/Transui/AppModel.swift new file mode 100644 index 0000000..62f1911 --- /dev/null +++ b/macos/Sources/Transui/AppModel.swift @@ -0,0 +1,237 @@ +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 !transcriptionPlan.endpoints.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, + plan: transcriptionPlan, + 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 var transcriptionPlan: TranscriptionPlan { + TranscriptionPlan( + hosted: TranscriptionEndpoint( + label: "Hosted", + endpoint: settings.hostedEndpoint, + model: settings.hostedModel, + key: settings.hostedKey + ), + local: TranscriptionEndpoint( + label: "Local", + endpoint: settings.localEndpoint, + model: settings.localModel, + key: settings.localKey + ), + hostedFirst: settings.hostedFirst + ) + } + + 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..f5aa11f --- /dev/null +++ b/macos/Sources/Transui/AppSettings.swift @@ -0,0 +1,90 @@ +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 hostedEndpoint = "endpoint" + static let hostedModel = "model" + static let localEndpoint = "local_endpoint" + static let localModel = "local_model" + static let hostedFirst = "hosted_first" + static let prompt = "prompt" + static let destination = "destination" + static let shortcut = "shortcut" + } + + private let defaults: UserDefaults + + @Published var hostedEndpoint: String { + didSet { defaults.set(hostedEndpoint, forKey: Key.hostedEndpoint) } + } + @Published var hostedModel: String { + didSet { defaults.set(hostedModel, forKey: Key.hostedModel) } + } + @Published var localEndpoint: String { + didSet { defaults.set(localEndpoint, forKey: Key.localEndpoint) } + } + @Published var localModel: String { + didSet { defaults.set(localModel, forKey: Key.localModel) } + } + @Published var hostedFirst: Bool { + didSet { defaults.set(hostedFirst, forKey: Key.hostedFirst) } + } + @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 + hostedEndpoint = defaults.string(forKey: Key.hostedEndpoint) ?? "" + hostedModel = defaults.string(forKey: Key.hostedModel) ?? "" + localEndpoint = defaults.string(forKey: Key.localEndpoint) ?? "http://localhost:8080" + localModel = defaults.string(forKey: Key.localModel) ?? "" + hostedFirst = defaults.object(forKey: Key.hostedFirst) as? Bool ?? true + 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 hostedKey: String { + get { KeychainStore.read(.hosted) } + set { KeychainStore.write(newValue, account: .hosted) } + } + + var localKey: String { + get { KeychainStore.read(.local) } + set { KeychainStore.write(newValue, account: .local) } + } +} 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..b2d1fec --- /dev/null +++ b/macos/Sources/Transui/KeychainStore.swift @@ -0,0 +1,45 @@ +import Foundation +import Security + +enum KeychainAccount: String { + case hosted = "remote-api-key" + case local = "local-api-key" +} + +enum KeychainStore { + private static let service = "io.github.transui.macos" + + static func read(_ account: KeychainAccount) -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account.rawValue, + 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, account: KeychainAccount) { + let identity: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account.rawValue, + ] + 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..3f72f05 --- /dev/null +++ b/macos/Sources/Transui/SettingsView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var model: AppModel + @ObservedObject private var settings: AppSettings + @State private var hostedKey = "" + @State private var localKey = "" + + init(model: AppModel) { + self.model = model + settings = model.settings + } + + var body: some View { + Form { + Section("Hosted") { + TextField( + "Endpoint", text: $settings.hostedEndpoint, prompt: Text("https://api.example.com")) + TextField("Model", text: $settings.hostedModel) + SecureField("API key", text: $hostedKey) + .onChange(of: hostedKey) { _, value in settings.hostedKey = value } + } + + Section("Local fallback") { + Toggle("Hosted first, local fallback", isOn: $settings.hostedFirst) + TextField("Endpoint", text: $settings.localEndpoint) + TextField("Model", text: $settings.localModel) + SecureField("API key", text: $localKey) + .onChange(of: localKey) { _, value in settings.localKey = value } + } + + Section("Transcript") { + 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: 620) + .onAppear { + hostedKey = settings.hostedKey + localKey = settings.localKey + } + } +} 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..7a3e5f3 --- /dev/null +++ b/macos/Sources/Transui/TranscriptionClient.swift @@ -0,0 +1,161 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +enum TranscriptionError: LocalizedError { + case invalidEndpoint + case invalidResponse + case http(Int) + case allEndpointsFailed([String]) + + 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)." + case .allEndpointsFailed(let failures): failures.joined(separator: " ") + } + } +} + +struct TranscriptionEndpoint: Equatable { + let label: String + let endpoint: String + let model: String + let key: String + + var isConfigured: Bool { + !endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct TranscriptionPlan { + let hosted: TranscriptionEndpoint + let local: TranscriptionEndpoint + let hostedFirst: Bool + + var endpoints: [TranscriptionEndpoint] { + var result: [TranscriptionEndpoint] = [] + if hostedFirst, hosted.isConfigured { result.append(hosted) } + if local.isConfigured { result.append(local) } + return result + } +} + +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, + plan: TranscriptionPlan, + prompt: String + ) async throws -> String { + let endpoints = plan.endpoints + guard !endpoints.isEmpty else { throw TranscriptionError.invalidEndpoint } + + var failures: [String] = [] + for endpoint in endpoints { + do { + return try await transcribe( + recording: recording, + endpoint: endpoint.endpoint, + model: endpoint.model, + key: endpoint.key, + prompt: prompt + ) + } catch { + failures.append("\(endpoint.label): \(error.localizedDescription)") + } + } + throw TranscriptionError.allEndpointsFailed(failures) + } + + private 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..a6295f5 --- /dev/null +++ b/macos/Tests/TransuiTests/TranscriptionClientTests.swift @@ -0,0 +1,60 @@ +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")) + } + + func testHostedFirstPlanFallsBackToLocal() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", "https://hosted.example.com"), + local: endpoint("Local", "http://localhost:8080"), + hostedFirst: true + ) + + XCTAssertEqual(plan.endpoints.map(\.label), ["Hosted", "Local"]) + } + + func testDisabledHostedFirstUsesOnlyLocal() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", "https://hosted.example.com"), + local: endpoint("Local", "http://localhost:8080"), + hostedFirst: false + ) + + XCTAssertEqual(plan.endpoints.map(\.label), ["Local"]) + } + + func testPlanSkipsEmptyEndpoints() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", ""), + local: endpoint("Local", ""), + hostedFirst: true + ) + + XCTAssertTrue(plan.endpoints.isEmpty) + } + + private func endpoint(_ label: String, _ value: String) -> TranscriptionEndpoint { + TranscriptionEndpoint(label: label, endpoint: value, model: "", key: "") + } +} diff --git a/macos/Transui.entitlements b/macos/Transui.entitlements new file mode 100644 index 0000000..d459cb2 --- /dev/null +++ b/macos/Transui.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/macos/project.yml b/macos/project.yml new file mode 100644 index 0000000..8d44782 --- /dev/null +++ b/macos/project.yml @@ -0,0 +1,67 @@ +name: Transui + +options: + createIntermediateGroups: true + deploymentTarget: + macOS: "15.0" + xcodeVersion: "16.0" + +configs: + Debug: debug + Release: release + +settings: + base: + CODE_SIGN_STYLE: Automatic + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 0.1.0 + SWIFT_VERSION: 5.0 + +targets: + Transui: + type: application + platform: macOS + deploymentTarget: "15.0" + sources: + - path: Sources/Transui + settings: + base: + CODE_SIGN_ENTITLEMENTS: Transui.entitlements + ENABLE_HARDENED_RUNTIME: "YES" + GENERATE_INFOPLIST_FILE: "NO" + INFOPLIST_FILE: Info.plist + PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos + PRODUCT_NAME: Transui + + TransuiTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "15.0" + sources: + - path: Tests/TransuiTests + dependencies: + - target: Transui + settings: + base: + GENERATE_INFOPLIST_FILE: "YES" + PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos.tests + +schemes: + Transui: + build: + targets: + Transui: all + TransuiTests: [test] + run: + config: Debug + test: + config: Debug + gatherCoverageData: true + targets: + - TransuiTests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release