6 Commits
Author SHA1 Message Date
benhaotang 6011b85abb feat:gen-xcode-proj 2026-08-29 20:16:22 +02:00
intelligence 6c0a6eb498 feat:xcode 2026-08-29 20:04:50 +02:00
intelligence 3400e2b465 feat:mac os 2026-08-29 19:58:33 +02:00
intelligence 136bf7cfa3 chore: README 2026-08-29 19:36:39 +02:00
intelligence d63e8dd703 chore: unslop 2026-08-29 19:25:52 +02:00
jourintownandintelligence ca1437a4fa Android ui upgrade (#1)
---------

Co-authored-by: intelligence <intelligence@debian.local>
Reviewed-on: #1
2026-08-29 19:22:18 +02:00
33 changed files with 1334 additions and 69 deletions

No files matched your search

+5 -11
View File
@@ -1,8 +1,6 @@
name: Android
on:
push:
pull_request:
release:
types: [published]
workflow_dispatch:
@@ -22,16 +20,11 @@ jobs:
- uses: android-actions/setup-android@v3
# Gitea's act resolver cannot peel the annotated v4 tag alias.
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
cache-read-only: ${{ gitea.event_name == 'pull_request' }}
- name: Install Android SDK packages
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
- name: Lint and test
working-directory: android
run: ./gradlew --no-daemon lintDebug testDebugUnitTest
- name: Build debug APK
working-directory: android
run: ./gradlew --no-daemon assembleDebug
- name: Build signed release APK
working-directory: android
env:
@@ -52,12 +45,13 @@ jobs:
working-directory: android
run: |
mkdir -p ../artifacts
cp app/build/outputs/apk/debug/app-debug.apk ../artifacts/transui-android-debug.apk
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
cp app/build/outputs/apk/release/app-release.apk ../artifacts/transui-android-release.apk
cd ../artifacts
sha256sum ./*.apk > SHA256SUMS-android
else
echo "No release APK to publish"
fi
cd ../artifacts
sha256sum ./*.apk > SHA256SUMS-android
- name: Attach APKs to the Gitea release
if: gitea.event_name == 'release'
env:
@@ -72,4 +66,4 @@ jobs:
with:
name: transui-android
path: artifacts/
if-no-files-found: error
if-no-files-found: warn
+1
View File
@@ -4,3 +4,4 @@
/android/build/
/android/local.properties
/android/app/build/
.DS_Store
+26
View File
@@ -0,0 +1,26 @@
# 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.
- 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 …<astext>` or `language …<asr_text>` prefix.
- 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`
is required; `prompt` is optional and may request transcription or translation.
```sh
curl -s -X POST -F response_format=json http://localhost:8080/v1/audio/transcriptions \
-F file="@test.mp3" \
-F 'prompt=Transcribe the following speech segment in English, and translate them into chinese.'
```
llama.cpp may return an event envelope instead of a bare OpenAI response:
```json
{"type":"transcript.text.done","text":"这是个测试。你能听到什么?我希望这很好。","usage":{"type":"tokens","input_tokens":241,"output_tokens":516,"total_tokens":757,"input_tokens_details":{"cached_tokens":0}}}
```
Use `text` as the transcript. Accept `transcript.text.done`; do not require a
bare `{ "text": "…" }` response.
+10 -18
View File
@@ -1,7 +1,8 @@
# Transui
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp
`/v1/audio/transcriptions` endpoints.
`/v1/audio/transcriptions` endpoints. It also includes a remote-only Android
speech-recognition provider and voice input method.
## Dependencies
@@ -13,11 +14,6 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \
libjson-glib-dev wtype xdotool
```
`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so
you only need the one matching your session. API keys are saved in the
freedesktop Secret Service (GNOME Keyring, KWallet Secret Service, and so on),
not in the settings file.
## Build
```sh
@@ -26,6 +22,14 @@ 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:
```sh
cd android
./gradlew lintDebug lintRelease testDebugUnitTest
```
## Commands
```sh
@@ -33,15 +37,3 @@ transui settings # configure endpoints, prompt, and destination
transui record # open the record window
transui # same as record
```
Bind `transui record` to a shortcut in your desktop environment. Transui does
not register a global shortcut itself. Press the same shortcut again to stop
and transcribe the current recording. Audio is recorded as 16 kHz mono MP3 at
32 kbps to keep uploads small.
The **Hosted first, local fallback** setting tries the Hosted endpoint, then
the Local endpoint when the first request fails. With it disabled, Transui uses
the Local endpoint only.
Both a normal response (`{"text":"..."}`) and llama.cpp event response
(`{"type":"transcript.text.done","text":"..."}`) are accepted.
@@ -1,17 +1,34 @@
package io.github.transui.android;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ImageButton;
public final class PushToTalkButton extends ImageButton {
enum IconMode {
APPS,
STOP_CIRCLE
}
private final Paint iconPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private Runnable pressedAction;
private Runnable releasedAction;
private boolean trackingPress;
private IconMode iconMode = IconMode.STOP_CIRCLE;
public PushToTalkButton(Context context, AttributeSet attributes) {
super(context, attributes);
iconPaint.setColor(context.getColor(R.color.on_signal));
setImageDrawable(null);
}
void setIconMode(IconMode iconMode) {
this.iconMode = iconMode;
setImageDrawable(null);
invalidate();
}
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
@@ -55,4 +72,48 @@ public final class PushToTalkButton extends ImageButton {
public boolean performClick() {
return super.performClick();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
float size = Math.min(contentWidth, contentHeight);
float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;
if (iconMode == IconMode.APPS) {
drawAppsIcon(canvas, centerX, centerY, size);
} else {
drawStopCircleIcon(canvas, centerX, centerY, size);
}
}
private void drawAppsIcon(Canvas canvas, float centerX, float centerY, float size) {
iconPaint.setStyle(Paint.Style.FILL);
float spacing = size * 0.27f;
float radius = size * 0.09f;
for (int row = -1; row <= 1; row++) {
for (int column = -1; column <= 1; column++) {
canvas.drawCircle(
centerX + column * spacing,
centerY + row * spacing,
radius,
iconPaint);
}
}
}
private void drawStopCircleIcon(Canvas canvas, float centerX, float centerY, float size) {
iconPaint.setStyle(Paint.Style.STROKE);
iconPaint.setStrokeWidth(size * 0.12f);
canvas.drawCircle(centerX, centerY, size * 0.42f, iconPaint);
iconPaint.setStyle(Paint.Style.FILL);
float squareHalfSize = size * 0.17f;
canvas.drawRect(
centerX - squareHalfSize,
centerY - squareHalfSize,
centerX + squareHalfSize,
centerY + squareHalfSize,
iconPaint);
}
}
@@ -80,25 +80,29 @@ public final class SpeechRecognitionActivity extends Activity {
private void configureRecordButton() {
record.setAlpha(1);
resizeRecordButton(config.pushToTalk ? 80 : 64, config.pushToTalk ? 23 : 19);
if (config.pushToTalk) {
record.setImageResource(R.drawable.ic_mic);
record.setBackgroundResource(R.drawable.voice_pill_button);
resizeRecordButton(132, 64, 16);
record.setIconMode(PushToTalkButton.IconMode.APPS);
record.setContentDescription(getString(R.string.hold_to_talk));
record.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
record.setImageResource(R.drawable.ic_stop);
record.setBackgroundResource(R.drawable.voice_primary_button);
resizeRecordButton(64, 64, 19);
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
private void resizeRecordButton(int sizeDp, int paddingDp) {
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
float density = getResources().getDisplayMetrics().density;
int size = Math.round(sizeDp * density);
int width = Math.round(widthDp * density);
int height = Math.round(heightDp * density);
int padding = Math.round(paddingDp * density);
ViewGroup.LayoutParams parameters = record.getLayoutParams();
parameters.width = size;
parameters.height = size;
parameters.width = width;
parameters.height = height;
record.setLayoutParams(parameters);
record.setPadding(padding, padding, padding, padding);
}
@@ -194,7 +198,9 @@ public final class SpeechRecognitionActivity extends Activity {
public void onRecording() {
runOnUiThread(() -> {
status.setText(R.string.listening);
if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
if (!config.pushToTalk) {
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
}
recordingStartedAt = SystemClock.elapsedRealtime();
elapsed.setText(R.string.elapsed_zero);
handler.post(updateElapsed);
@@ -109,25 +109,29 @@ public final class TransuiInputMethodService extends InputMethodService {
record.setAlpha(1);
record.setOnClickListener(null);
record.setPushToTalkActions(null, null);
resizeRecordButton(config.pushToTalk ? 76 : 64, config.pushToTalk ? 22 : 19);
if (config.pushToTalk) {
record.setImageResource(R.drawable.ic_mic);
record.setBackgroundResource(R.drawable.voice_pill_button);
resizeRecordButton(132, 64, 16);
record.setIconMode(PushToTalkButton.IconMode.APPS);
record.setContentDescription(getString(R.string.hold_to_talk));
record.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
record.setImageResource(R.drawable.ic_stop);
record.setBackgroundResource(R.drawable.voice_primary_button);
resizeRecordButton(64, 64, 19);
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
private void resizeRecordButton(int sizeDp, int paddingDp) {
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
float density = getResources().getDisplayMetrics().density;
int size = Math.round(sizeDp * density);
int width = Math.round(widthDp * density);
int height = Math.round(heightDp * density);
int padding = Math.round(paddingDp * density);
ViewGroup.LayoutParams parameters = record.getLayoutParams();
parameters.width = size;
parameters.height = size;
parameters.width = width;
parameters.height = height;
record.setLayoutParams(parameters);
record.setPadding(padding, padding, padding, padding);
}
@@ -176,7 +180,9 @@ public final class TransuiInputMethodService extends InputMethodService {
public void onRecording() {
handler.post(() -> {
status.setText(R.string.listening);
if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
if (!config.pushToTalk) {
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
}
});
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z" />
</vector>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M6,6h12v12H6z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple_on_signal">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/signal" />
<corners android:radius="18dp" />
</shape>
</item>
</ripple>
@@ -60,9 +60,7 @@
android:layout_marginTop="14dp"
android:background="@drawable/voice_primary_button"
android:contentDescription="@string/stop"
android:padding="16dp"
android:src="@drawable/ic_stop"
android:tint="@color/paper" />
android:padding="16dp" />
<Button
android:id="@+id/recognition_cancel"
@@ -75,8 +75,6 @@
android:layout_gravity="center"
android:background="@drawable/voice_primary_button"
android:contentDescription="@string/stop"
android:padding="21dp"
android:src="@drawable/ic_stop"
android:tint="@color/paper" />
android:padding="21dp" />
</FrameLayout>
</LinearLayout>
@@ -6,6 +6,7 @@
<color name="mist">#11191D</color>
<color name="paper">#192329</color>
<color name="line">#34444B</color>
<color name="on_signal">#142027</color>
<color name="ripple_on_signal">#3DFFFFFF</color>
<color name="ripple_signal">#3342C9C1</color>
</resources>
@@ -6,6 +6,7 @@
<color name="mist">#F2F6F7</color>
<color name="paper">#FFFFFF</color>
<color name="line">#D5E0E3</color>
<color name="on_signal">#FFFFFF</color>
<color name="ripple_on_signal">#3DFFFFFF</color>
<color name="ripple_signal">#33008D87</color>
</resources>
+2
View File
@@ -0,0 +1,2 @@
DerivedData/
Transui.xcodeproj/
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Transui</string>
<key>CFBundleIdentifier</key>
<string>io.github.transui.macos</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Transui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Transui records speech to transcribe it.</string>
</dict>
</plist>
+221
View File
@@ -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<AnyCancellable>()
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)
}
}
+66
View File
@@ -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) }
}
}
+86
View File
@@ -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
}
}
}
+70
View File
@@ -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<EventHotKeyID>.size,
nil,
&identifier
)
guard status == noErr, identifier.id == 1 else { return status }
let manager = Unmanaged<HotkeyManager>.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
}
@@ -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()
}
}
@@ -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",
]
}
+41
View File
@@ -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)
}
}
}
+133
View File
@@ -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)
}
}
+61
View File
@@ -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 }
}
}
@@ -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 = ["<asr_text>", "<astext>"]
let marker = markers.compactMap { text.range(of: $0, range: prefix..<text.endIndex) }
.min { $0.lowerBound < $1.lowerBound }
guard let marker else { return text }
return String(text[marker.upperBound...])
}
}
@@ -0,0 +1,43 @@
import AppKit
import ApplicationServices
import Carbon.HIToolbox
enum OutputResult {
case typed
case copied
case copiedBecauseAccessibilityIsOff
}
enum TranscriptOutput {
@MainActor
static func deliver(_ text: String, to destination: TranscriptDestination) -> 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
}
}
@@ -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)
}
}
+44
View File
@@ -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)
}
}
@@ -0,0 +1,30 @@
import XCTest
@testable import Transui
final class TranscriptCleanerTests: XCTestCase {
func testRemovesAsTextPrefix() {
XCTAssertEqual(
TranscriptCleaner.clean("language English<astext>Hello there"),
"Hello there"
)
}
func testRemovesAsrTextPrefix() {
XCTAssertEqual(
TranscriptCleaner.clean("language zh <asr_text>你好"),
"你好"
)
}
func testRemovesPrefixAfterWhitespaceAndBom() {
XCTAssertEqual(
TranscriptCleaner.clean("\u{FEFF} \n\tlanguage English<astext>Hello"),
"Hello"
)
}
func testPreservesOrdinaryTranscript() {
XCTAssertEqual(TranscriptCleaner.clean("language is useful"), "language is useful")
}
}
@@ -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"))
}
}
+8
View File
@@ -0,0 +1,8 @@
<?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.device.audio-input</key>
<true/>
</dict>
</plist>
+67
View File
@@ -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