Initial open source preview

This commit is contained in:
cmagnussen 2026-05-21 14:16:04 +02:00
commit 45191f4398
72 changed files with 6686 additions and 0 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @cmagnussen

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,30 @@
---
name: Bug report
about: Report something broken in the macOS preview
title: ""
labels: bug
assignees: ""
---
## What happened?
## What did you expect?
## Steps to reproduce
1.
2.
3.
## Environment
- macOS version:
- Mac model/chip:
- Xcode version:
- Did this happen before or after entering an OpenAI API key?:
## Notes
Do not paste API keys, private recordings, confidential transcripts, or screenshots with sensitive content.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for the preview
title: ""
labels: enhancement
assignees: ""
---
## What would you like?
## Why does it matter?
## Possible approach
## Privacy or security impact
Would this send data to a new service, store more local data, or change how API keys are handled?

19
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,19 @@
## What changed?
## Why?
## How did you test it?
## AI assistance
Did you use AI-assisted coding tools? If yes, briefly mention where.
## Checklist
- [ ] I ran `./build.sh --debug` or explained why not.
- [ ] I did not commit API keys, tokens, private recordings, or confidential transcripts.
- [ ] I considered whether this changes privacy, security, or data flow.
- [ ] I kept the change focused on the macOS preview scope.

7
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

32
.github/secret-scan-patterns.txt vendored Normal file
View File

@ -0,0 +1,32 @@
# Secret hygiene patterns used by .github/workflows/ci.yml.
# One extended-regex per line. Lines starting with `#` and blank lines are ignored.
# Keep this file out of the scan via --exclude in the workflow.
# OpenAI keys and config tokens
sk-[A-Za-z0-9_-]{20,}
OPENAI_API_KEY[[:space:]]*=
APP_SECRET[[:space:]]*=
# AWS access keys
AKIA[0-9A-Z]{16}
ASIA[0-9A-Z]{16}
# GitHub tokens
ghp_[A-Za-z0-9]{36}
ghs_[A-Za-z0-9]{36}
ghu_[A-Za-z0-9]{36}
gho_[A-Za-z0-9]{36}
github_pat_[A-Za-z0-9_]{20,}
# Slack tokens and webhooks
xox[abprs]-[A-Za-z0-9-]{10,}
hooks\.slack\.com/services/
# Discord webhooks
discord(app)?\.com/api/webhooks/
# Google API keys
AIza[0-9A-Za-z_-]{35}
# Generic private key blocks
-----BEGIN [A-Z ]*PRIVATE KEY-----

52
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
build-macos:
name: Build macOS app
runs-on: macos-14
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Secret hygiene scan
run: |
patterns_file=.github/secret-scan-patterns.txt
if [ ! -f "$patterns_file" ]; then
echo "Missing $patterns_file"
exit 1
fi
pattern="$(grep -vE '^[[:space:]]*(#|$)' "$patterns_file" | paste -sd'|' -)"
if [ -z "$pattern" ]; then
echo "No patterns loaded from $patterns_file"
exit 1
fi
if grep -RInE \
--exclude-dir=.git \
--exclude=secret-scan-patterns.txt \
"$pattern" .; then
echo "Potential secret or private project reference found."
exit 1
fi
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app || sudo xcode-select -s /Applications/Xcode.app
- name: Install XcodeGen
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Build
run: ./build.sh --debug

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Xcode
*.xcuserstate
*.xcuserdatad/
xcuserdata/
*.xcodeproj/
DerivedData/
.derivedData*/
build/
*.xcarchive
*.dSYM
*.dSYM.zip
# macOS
.DS_Store
# Local app artifacts
Blitztext.app
dist/
models/
*.mlmodel
*.mlmodelc/
*.mlpackage/
# Secrets and local config
.env
.env.*
*.local
Secrets.swift
*.xcconfig
# Tooling
node_modules/

View File

@ -0,0 +1,694 @@
import SwiftUI
import Observation
import AppKit
enum PopoverPage: Equatable {
case main
case onboarding
case settings
case workflow
}
@Observable
@MainActor
final class AppState {
private static let pasteRetryInitialAttempts = 22
private static let clipboardRestoreDelayAfterPaste: TimeInterval = 1.5
private static let clipboardFallbackRestoreDelay: TimeInterval = 60
private static let concealedPasteboardType = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
private static let pasteboardMarkerType = NSPasteboard.PasteboardType("app.blitztext.pasteboard-marker")
var activeWorkflow: (any Workflow)?
var page: PopoverPage = .main
var isPopoverShown = false
var menuBarStatus: MenuBarStatus = .idle {
didSet {
guard oldValue != menuBarStatus else { return }
onMenuBarStatusChange?(menuBarStatus)
}
}
var accessibilityPermissionGranted = false
var localModelDownloadProgress: Double?
var localModelDownloadStatusText: String?
var localModelDownloadErrorText: String?
var onMenuBarStatusChange: ((MenuBarStatus) -> Void)?
private var activeLaunchSource: WorkflowLaunchSource = .manual
private var activePasteTarget: PasteTarget?
private var lastPopoverPasteTarget: PasteTarget?
private var menuBarStatusResetTask: Task<Void, Never>?
private var workflowCleanupTask: Task<Void, Never>?
private var pasteboardCleanupTask: Task<Void, Never>?
// Persisted settings
var appSettings: AppSettings {
didSet {
saveSettings()
prewarmLocalTranscriptionIfNeeded()
}
}
var transcriptionSettings: TranscriptionSettings {
didSet { saveSettings() }
}
var textImprovementSettings: TextImprovementSettings {
didSet { saveSettings() }
}
var dampfAblassenSettings: DampfAblassenSettings {
didSet { saveSettings() }
}
var emojiTextSettings: EmojiTextSettings {
didSet { saveSettings() }
}
// Hotkeys
let hotkeyService = HotkeyService()
// Computed
var isConfigured: Bool {
KeychainService.isConfigured || !LocalTranscriptionService.installedModels().isEmpty
}
var shouldShowOnboarding: Bool {
!isConfigured && !appSettings.hasSeenOnboarding
}
var currentPhase: WorkflowPhase {
activeWorkflow?.phase ?? .idle
}
init() {
self.appSettings = Self.loadAppSettings()
self.transcriptionSettings = Self.loadTranscriptionSettings()
self.textImprovementSettings = Self.loadTextImprovementSettings()
self.dampfAblassenSettings = Self.loadDampfAblassenSettings()
self.emojiTextSettings = Self.loadEmojiTextSettings()
refreshAccessibilityPermission()
autoSelectFastLocalModelIfNeeded()
prewarmLocalTranscriptionIfNeeded()
}
// MARK: - Custom Display Names
func displayName(for type: WorkflowType) -> String {
switch type {
case .textImprover:
let name = textImprovementSettings.customName.trimmingCharacters(in: .whitespaces)
return name.isEmpty ? type.displayName : name
case .dampfAblassen:
let name = dampfAblassenSettings.customName.trimmingCharacters(in: .whitespaces)
return name.isEmpty ? type.displayName : name
case .emojiText:
let name = emojiTextSettings.customName.trimmingCharacters(in: .whitespaces)
return name.isEmpty ? type.displayName : name
default:
return type.displayName
}
}
func workflowSubtitle(for type: WorkflowType) -> String {
switch type {
case .transcription:
if appSettings.secureLocalModeEnabled {
let modelName = selectedLocalModelName
return LocalTranscriptionService.isModelInstalled(modelName)
? "Lokal: \(LocalTranscriptionModel.displayName(for: modelName))."
: "Lokales WhisperKit-Modell fehlt."
}
return "Online: Whisper über OpenAI."
case .localTranscription:
return "Nur lokal. Kein Server."
case .textImprover, .dampfAblassen, .emojiText:
if appSettings.secureLocalModeEnabled {
return "Im lokalen Modus pausiert."
}
return type.subtitle
}
}
var resolvedLocalModelName: String {
LocalTranscriptionService.resolvedModelName(appSettings.selectedLocalTranscriptionModelName)
}
var selectedLocalModelDisplayName: String {
LocalTranscriptionModel.displayName(for: selectedLocalModelName)
}
var selectedLocalModelName: String {
LocalTranscriptionService.normalizedModelName(appSettings.selectedLocalTranscriptionModelName)
}
var selectedLocalModelIsInstalled: Bool {
LocalTranscriptionService.isModelInstalled(selectedLocalModelName)
}
var isDownloadingLocalModel: Bool {
localModelDownloadProgress != nil
}
var localModelDownloadButtonTitle: String {
selectedLocalModelIsInstalled
? "\(LocalTranscriptionModel.displayName(for: selectedLocalModelName)) ist installiert"
: "\(LocalTranscriptionModel.displayName(for: selectedLocalModelName)) installieren"
}
// MARK: - Workflow Management
func startWorkflow(_ type: WorkflowType, source: WorkflowLaunchSource = .manual) {
guard isWorkflowAvailable(type) else {
if source == .manual {
page = .settings
}
return
}
activeWorkflow?.stop()
menuBarStatusResetTask?.cancel()
workflowCleanupTask?.cancel()
activeLaunchSource = source
activePasteTarget = capturePasteTarget(for: source)
switch type {
case .transcription:
let workflow = TranscriptionWorkflow(
customTerms: textImprovementSettings.customTerms,
language: transcriptionSettings.language,
backend: appSettings.secureLocalModeEnabled ? .local : .remote,
localModelName: selectedLocalModelName
)
configureWorkflowHandlers(workflow)
activeWorkflow = workflow
workflow.start()
case .localTranscription:
let workflow = TranscriptionWorkflow(
type: .localTranscription,
customTerms: textImprovementSettings.customTerms,
language: transcriptionSettings.language,
backend: .local,
localModelName: selectedLocalModelName
)
configureWorkflowHandlers(workflow)
activeWorkflow = workflow
workflow.start()
case .textImprover:
let workflow = TextImprovementWorkflow(
settings: textImprovementSettings,
language: transcriptionSettings.language
)
configureWorkflowHandlers(workflow)
activeWorkflow = workflow
workflow.start()
case .dampfAblassen:
let workflow = DampfAblassenWorkflow(
settings: dampfAblassenSettings,
customTerms: textImprovementSettings.customTerms,
language: transcriptionSettings.language
)
configureWorkflowHandlers(workflow)
activeWorkflow = workflow
workflow.start()
case .emojiText:
let workflow = EmojiTextWorkflow(
settings: emojiTextSettings,
customTerms: textImprovementSettings.customTerms,
language: transcriptionSettings.language
)
configureWorkflowHandlers(workflow)
activeWorkflow = workflow
workflow.start()
}
page = source.presentsWorkflowPage ? .workflow : .main
}
func isWorkflowAvailable(_ type: WorkflowType) -> Bool {
switch type {
case .localTranscription:
return selectedLocalModelIsInstalled
case .transcription:
return appSettings.secureLocalModeEnabled
? selectedLocalModelIsInstalled
: KeychainService.isConfigured
case .textImprover, .dampfAblassen, .emojiText:
return !appSettings.secureLocalModeEnabled && KeychainService.isConfigured
}
}
func stopCurrentWorkflow() {
activeWorkflow?.stop()
}
func resetCurrentWorkflow() {
activeWorkflow?.reset()
activeWorkflow = nil
activePasteTarget = nil
activeLaunchSource = .manual
menuBarStatusResetTask?.cancel()
workflowCleanupTask?.cancel()
menuBarStatus = .idle
page = .main
}
func enableSecureLocalMode() {
appSettings.secureLocalModeEnabled = true
if !selectedLocalModelIsInstalled {
installSelectedLocalModel()
}
}
func installSelectedLocalModel() {
guard !isDownloadingLocalModel else { return }
let modelName = selectedLocalModelName
localModelDownloadProgress = 0
localModelDownloadStatusText = "Download startet..."
localModelDownloadErrorText = nil
Task {
do {
let installedURL = try await LocalTranscriptionService.shared.downloadAndInstall(
modelName: modelName
) { [weak self] progress in
Task { @MainActor [weak self] in
guard let self else { return }
let clampedProgress = min(max(progress, 0), 1)
self.localModelDownloadProgress = clampedProgress
self.localModelDownloadStatusText = "Download \(Int(clampedProgress * 100)) %"
}
}
appSettings.selectedLocalTranscriptionModelName = installedURL.lastPathComponent
appSettings.secureLocalModeEnabled = true
localModelDownloadProgress = nil
localModelDownloadStatusText = "\(LocalTranscriptionModel.displayName(for: modelName)) ist installiert."
localModelDownloadErrorText = nil
try? await LocalTranscriptionService.shared.prepare(modelName: modelName)
} catch {
localModelDownloadProgress = nil
localModelDownloadStatusText = nil
localModelDownloadErrorText = error.localizedDescription
}
}
}
func copyToClipboard(_ text: String) {
_ = writeSensitiveTextToPasteboard(text)
}
// MARK: - Auto-Paste
/// Copies the text, restores focus when needed, then simulates Cmd+V.
/// The previous clipboard content is restored after paste when possible.
private func pasteAtCursor(_ text: String, target: PasteTarget? = nil) {
let pasteboardState = writeSensitiveTextToPasteboard(text)
schedulePasteboardRestore(
marker: pasteboardState.marker,
previousContents: pasteboardState.previousContents,
after: Self.clipboardFallbackRestoreDelay
)
if isPopoverShown {
NotificationCenter.default.post(name: .dismissPopover, object: nil)
}
let trusted = AccessibilityPermissionService.isTrusted(promptIfNeeded: true)
accessibilityPermissionGranted = trusted
guard trusted else {
menuBarStatus = .error(activeWorkflow?.type)
return
}
attemptPasteTrusted(
target: target,
attemptsRemaining: Self.pasteRetryInitialAttempts,
pasteboardState: pasteboardState
)
}
private func writeSensitiveTextToPasteboard(_ text: String) -> (marker: String, previousContents: PasteboardSnapshot) {
let pasteboard = NSPasteboard.general
let previousContents = PasteboardSnapshot.capture(from: pasteboard)
let marker = UUID().uuidString
pasteboard.clearContents()
pasteboard.declareTypes([.string, Self.concealedPasteboardType, Self.pasteboardMarkerType], owner: nil)
pasteboard.setString(text, forType: .string)
pasteboard.setString("", forType: Self.concealedPasteboardType)
pasteboard.setString(marker, forType: Self.pasteboardMarkerType)
return (marker, previousContents)
}
private func schedulePasteboardRestore(
marker: String,
previousContents: PasteboardSnapshot,
after delay: TimeInterval
) {
pasteboardCleanupTask?.cancel()
pasteboardCleanupTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self else { return }
self.restorePasteboardIfCurrent(marker: marker, previousContents: previousContents)
}
}
private func restorePasteboardIfCurrent(marker: String, previousContents: PasteboardSnapshot) {
let pasteboard = NSPasteboard.general
guard pasteboard.string(forType: Self.pasteboardMarkerType) == marker else {
return
}
previousContents.restore(to: pasteboard)
}
func prepareForPopoverPresentation() {
lastPopoverPasteTarget = captureCurrentFrontmostApp()
if let activeWorkflow, activeWorkflow.phase.isActive {
page = .workflow
} else if shouldShowOnboarding {
page = .onboarding
markOnboardingSeen()
} else if page == .workflow {
page = .main
} else if page == .onboarding {
page = .main
}
}
func markOnboardingSeen() {
guard !appSettings.hasSeenOnboarding else { return }
appSettings.hasSeenOnboarding = true
}
// MARK: - API Key Status
func apiKeyDisplayValue(for key: KeychainKey) -> String {
guard let value = KeychainService.load(key: key), !value.isEmpty else {
return ""
}
if value.count > 8 {
return String(value.prefix(4)) + " \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
}
return "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
}
func hasValue(for key: KeychainKey) -> Bool {
guard let value = KeychainService.load(key: key) else { return false }
return !value.isEmpty
}
// MARK: - Settings Persistence
private static let settingsURL: URL = {
try? AppSupportPaths.ensureAppSupportDirectoryExists()
return AppSupportPaths.settingsURL
}()
private func saveSettings() {
let container = SettingsContainer(
app: appSettings,
transcription: transcriptionSettings,
textImprovement: textImprovementSettings,
dampfAblassen: dampfAblassenSettings,
emojiText: emojiTextSettings
)
if let data = try? JSONEncoder().encode(container) {
try? data.write(to: Self.settingsURL)
}
}
private static func loadAppSettings() -> AppSettings {
loadContainer()?.app ?? AppSettings()
}
private static func loadTranscriptionSettings() -> TranscriptionSettings {
loadContainer()?.transcription ?? TranscriptionSettings()
}
private static func loadTextImprovementSettings() -> TextImprovementSettings {
loadContainer()?.textImprovement ?? TextImprovementSettings()
}
private static func loadDampfAblassenSettings() -> DampfAblassenSettings {
loadContainer()?.dampfAblassen ?? DampfAblassenSettings()
}
private static func loadEmojiTextSettings() -> EmojiTextSettings {
loadContainer()?.emojiText ?? EmojiTextSettings()
}
private static func loadContainer() -> SettingsContainer? {
guard let data = try? Data(contentsOf: settingsURL) else { return nil }
return try? JSONDecoder().decode(SettingsContainer.self, from: data)
}
func refreshAccessibilityPermission() {
accessibilityPermissionGranted = AccessibilityPermissionService.currentStatus()
}
func requestAccessibilityPermission() {
accessibilityPermissionGranted = AccessibilityPermissionService.requestPermissionPrompt()
AccessibilityPermissionService.openSystemSettings()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.refreshAccessibilityPermission()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
self?.refreshAccessibilityPermission()
}
}
private func autoSelectFastLocalModelIfNeeded() {
guard !appSettings.hasAutoSelectedFastLocalModel,
LocalTranscriptionService.shouldAutoSelectRecommendedFastModel(
currentModelName: appSettings.selectedLocalTranscriptionModelName
) else {
return
}
appSettings.selectedLocalTranscriptionModelName = LocalTranscriptionService.recommendedFastModelName
appSettings.hasAutoSelectedFastLocalModel = true
}
private func prewarmLocalTranscriptionIfNeeded() {
guard appSettings.secureLocalModeEnabled,
LocalTranscriptionService.isModelInstalled(resolvedLocalModelName) else {
return
}
let modelName = resolvedLocalModelName
Task.detached(priority: .utility) {
try? await LocalTranscriptionService.shared.prepare(modelName: modelName)
}
}
private func handleWorkflowOutput(_ text: String) {
pasteAtCursor(text, target: activePasteTarget)
if activeLaunchSource == .hotkeyBackground {
page = .main
}
scheduleWorkflowCleanup(after: 1.05)
}
private func configureWorkflowHandlers<T: Workflow>(_ workflow: T) {
workflow.onOutput = { [weak self] text in
self?.handleWorkflowOutput(text)
}
workflow.onPhaseChange = { [weak self, weak workflow] phase in
guard let self, let workflow else { return }
self.handleWorkflowPhaseChange(phase, workflow: workflow)
}
}
private func handleWorkflowPhaseChange(_ phase: WorkflowPhase, workflow: any Workflow) {
menuBarStatusResetTask?.cancel()
switch phase {
case .idle:
if activeWorkflow == nil {
menuBarStatus = .idle
}
case .running:
menuBarStatus = workflow.isRecording
? .recording(workflow.type)
: .processing(workflow.type)
case .done:
menuBarStatus = .success(workflow.type)
case .error:
menuBarStatus = .error(workflow.type)
if activeLaunchSource == .hotkeyBackground {
activeWorkflow = nil
activePasteTarget = nil
page = .main
}
scheduleMenuBarStatusReset(after: 1.6)
}
}
private func scheduleWorkflowCleanup(after delay: TimeInterval) {
guard let workflow = activeWorkflow else { return }
workflowCleanupTask?.cancel()
let workflowID = ObjectIdentifier(workflow)
workflowCleanupTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, let activeWorkflow = self.activeWorkflow else { return }
guard ObjectIdentifier(activeWorkflow) == workflowID else { return }
activeWorkflow.reset()
self.activeWorkflow = nil
self.activePasteTarget = nil
self.activeLaunchSource = .manual
if !self.isPopoverShown {
self.page = .main
}
self.menuBarStatus = .idle
}
}
private func scheduleMenuBarStatusReset(after delay: TimeInterval) {
menuBarStatusResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self else { return }
if self.activeWorkflow == nil || !(self.activeWorkflow?.phase.isActive ?? false) {
self.menuBarStatus = .idle
}
}
}
private func capturePasteTarget(for source: WorkflowLaunchSource) -> PasteTarget? {
switch source {
case .manual:
return lastPopoverPasteTarget
case .hotkeyBackground:
return captureCurrentFrontmostApp()
}
}
private func attemptPasteTrusted(
target: PasteTarget?,
attemptsRemaining: Int,
pasteboardState: (marker: String, previousContents: PasteboardSnapshot)
) {
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
if let target {
if frontmostPid == target.processIdentifier {
performPaste()
schedulePasteboardRestore(
marker: pasteboardState.marker,
previousContents: pasteboardState.previousContents,
after: Self.clipboardRestoreDelayAfterPaste
)
return
}
target.application.activate(options: [])
} else {
return
}
guard attemptsRemaining > 0 else {
return
}
let delay: TimeInterval
switch attemptsRemaining {
case 16...:
delay = 0.015
case 8...15:
delay = 0.025
default:
delay = 0.04
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.attemptPasteTrusted(
target: target,
attemptsRemaining: attemptsRemaining - 1,
pasteboardState: pasteboardState
)
}
}
private func performPaste() {
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
keyDown?.flags = .maskCommand
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
keyUp?.flags = .maskCommand
keyDown?.post(tap: .cghidEventTap)
keyUp?.post(tap: .cghidEventTap)
}
private func captureCurrentFrontmostApp() -> PasteTarget? {
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
let ownPid = NSRunningApplication.current.processIdentifier
guard app.processIdentifier != ownPid else { return nil }
return PasteTarget(
bundleIdentifier: app.bundleIdentifier,
processIdentifier: app.processIdentifier,
application: app
)
}
}
private struct SettingsContainer: Codable {
var app: AppSettings?
var transcription: TranscriptionSettings
var textImprovement: TextImprovementSettings
var dampfAblassen: DampfAblassenSettings?
var emojiText: EmojiTextSettings?
}
// MARK: - Notification for Popover Dismissal
extension Notification.Name {
static let dismissPopover = Notification.Name("dismissPopover")
}
private struct PasteTarget {
let bundleIdentifier: String?
let processIdentifier: pid_t
let application: NSRunningApplication
}
private struct PasteboardSnapshot {
private let items: [[NSPasteboard.PasteboardType: Data]]
static func capture(from pasteboard: NSPasteboard) -> PasteboardSnapshot {
let capturedItems = pasteboard.pasteboardItems?.map { item in
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { values, type in
values[type] = item.data(forType: type)
}
} ?? []
return PasteboardSnapshot(items: capturedItems)
}
func restore(to pasteboard: NSPasteboard) {
pasteboard.clearContents()
guard !items.isEmpty else {
return
}
let restoredItems = items.map { values in
let item = NSPasteboardItem()
for (type, data) in values {
item.setData(data, forType: type)
}
return item
}
pasteboard.writeObjects(restoredItems)
}
}

View File

@ -0,0 +1,153 @@
import SwiftUI
@main
struct BlitztextMacApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
EmptyView()
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private let menuBarStatusController = MenuBarStatusController()
let appState = AppState()
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
menuBarStatusController.attach(to: button)
button.action = #selector(togglePopover)
button.target = self
}
popover = NSPopover()
popover.contentSize = NSSize(width: 340, height: 480)
popover.behavior = .transient
popover.delegate = self
popover.contentViewController = NSHostingController(rootView: MenuBarView(appState: appState))
NSApp.setActivationPolicy(.accessory)
// Hotkey events
appState.hotkeyService.onHotkeyEvent = { [weak self] event in
self?.handleHotkeyEvent(event)
}
appState.onMenuBarStatusChange = { [weak self] status in
self?.menuBarStatusController.update(to: status)
}
appState.hotkeyService.start()
// Listen for popover dismiss requests (from auto-paste)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDismissPopover),
name: .dismissPopover,
object: nil
)
DispatchQueue.main.async { [weak self] in
self?.showOnboardingIfNeeded()
}
}
@objc private func handleDismissPopover() {
appState.isPopoverShown = false
popover.performClose(nil)
}
private func handleHotkeyEvent(_ event: HotkeyEvent) {
switch event {
case .down(let type):
handleHotkeyDown(type)
case .up(let type):
handleHotkeyUp(type)
case .cancel:
handleHotkeyCancel()
}
}
private func handleHotkeyDown(_ type: WorkflowType) {
guard appState.isConfigured else { return }
let mode = appState.appSettings.hotkeyMode
switch mode {
case .hold:
// Hold mode: start recording on key down
appState.startWorkflow(type, source: .hotkeyBackground)
case .toggle:
// Toggle mode: if already recording same workflow, stop it
if let active = appState.activeWorkflow,
active.type == type,
active.phase.isActive {
active.stop()
} else {
appState.prepareForPopoverPresentation()
appState.startWorkflow(type, source: .manual)
showPopover()
}
}
}
private func handleHotkeyUp(_ type: WorkflowType) {
let mode = appState.appSettings.hotkeyMode
guard mode == .hold else { return }
// Hold mode: stop recording on key release
if let active = appState.activeWorkflow,
active.type == type {
// Only stop if currently recording (running phase)
if case .running = active.phase {
active.stop()
}
}
}
private func handleHotkeyCancel() {
appState.activeWorkflow?.stop()
}
@objc private func togglePopover() {
if popover.isShown {
popover.performClose(nil)
appState.isPopoverShown = false
} else {
appState.prepareForPopoverPresentation()
showPopover()
}
}
private func showOnboardingIfNeeded() {
guard appState.shouldShowOnboarding else { return }
appState.prepareForPopoverPresentation()
showPopover()
}
private func showPopover() {
guard let button = statusItem.button else { return }
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
appState.isPopoverShown = true
NSApp.activate(ignoringOtherApps: true)
}
nonisolated func popoverDidClose(_ notification: Notification) {
Task { @MainActor in
appState.isPopoverShown = false
switch appState.currentPhase {
case .done, .error:
appState.resetCurrentWorkflow()
default:
appState.page = .main
}
}
}
}

View File

@ -0,0 +1,380 @@
import AppKit
enum MenuBarStatus: Equatable {
case idle
case recording(WorkflowType)
case processing(WorkflowType)
case success(WorkflowType?)
case error(WorkflowType?)
}
@MainActor
final class MenuBarStatusController {
private weak var button: NSStatusBarButton?
private var animationTimer: Timer?
private var animationFrame = 0
private var currentStatus: MenuBarStatus = .idle
func attach(to button: NSStatusBarButton) {
self.button = button
button.imagePosition = .imageOnly
button.imageScaling = .scaleProportionallyDown
renderCurrentStatus()
}
func update(to status: MenuBarStatus) {
currentStatus = status
animationFrame = 0
configureAnimationIfNeeded()
renderCurrentStatus()
}
private func configureAnimationIfNeeded() {
stopAnimation()
switch currentStatus {
case .recording:
startAnimation(interval: 0.12)
case .processing:
startAnimation(interval: 0.18)
default:
break
}
}
private func startAnimation(interval: TimeInterval) {
animationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.tick()
}
}
RunLoop.main.add(animationTimer!, forMode: .common)
}
private func stopAnimation() {
animationTimer?.invalidate()
animationTimer = nil
}
private func tick() {
animationFrame = (animationFrame + 1) % 4
renderCurrentStatus()
}
private func renderCurrentStatus() {
guard let button else { return }
button.image = MenuBarStatusIconRenderer.makeImage(for: currentStatus, frame: animationFrame)
button.image?.isTemplate = true
button.toolTip = tooltip(for: currentStatus)
}
private func tooltip(for status: MenuBarStatus) -> String {
switch status {
case .idle:
return "Blitztext ist bereit"
case .recording(let type):
return "\(type.displayName): Aufnahme läuft"
case .processing(let type):
return "\(type.displayName): Verarbeitung läuft"
case .success(let type):
if let type {
return "\(type.displayName): Fertig"
}
return "Blitztext: Fertig"
case .error(let type):
if let type {
return "\(type.displayName): Fehler"
}
return "Blitztext: Fehler"
}
}
deinit {
animationTimer?.invalidate()
}
}
private enum MenuBarStatusIconRenderer {
static func makeImage(for status: MenuBarStatus, frame: Int) -> NSImage {
if case .idle = status, let baseImage = baseTemplateImage() {
return baseImage
}
let size = NSSize(width: 18, height: 18)
let image = NSImage(size: size, flipped: false) { bounds in
drawBaseIcon(in: bounds, status: status, frame: frame)
switch status {
case .recording(let type):
drawActivityBadge(
type: type,
systemName: badgeSymbol(for: type),
in: bounds,
frame: frame,
phase: .recording
)
case .processing(let type):
drawActivityBadge(
type: type,
systemName: badgeSymbol(for: type),
in: bounds,
frame: frame,
phase: .processing
)
case .success:
drawBadge(systemName: "checkmark", in: bounds, fillOpacity: 1.0)
case .error:
drawBadge(systemName: "exclamationmark", in: bounds, fillOpacity: 1.0)
default:
break
}
return true
}
image.isTemplate = true
image.size = size
return image
}
private enum ActivityPhase {
case recording
case processing
}
private static func drawBaseIcon(in bounds: CGRect, status: MenuBarStatus, frame: Int) {
let stripeWidths: [CGFloat] = [12, 10, 8, 6]
let stripeHeight: CGFloat = 2
let stripeSpacing: CGFloat = 1.6
let totalHeight = (CGFloat(stripeWidths.count) * stripeHeight) + (CGFloat(stripeWidths.count - 1) * stripeSpacing)
let originY = bounds.midY - (totalHeight / 2)
let baseAlpha = baseAlphaValues(for: status, frame: frame)
for (index, width) in stripeWidths.enumerated() {
let x = bounds.midX - (width / 2)
let y = originY + CGFloat(index) * (stripeHeight + stripeSpacing)
let rect = CGRect(x: x, y: y, width: width, height: stripeHeight)
let path = NSBezierPath(roundedRect: rect, xRadius: 1, yRadius: 1)
NSColor.black.withAlphaComponent(baseAlpha[index]).setFill()
path.fill()
}
}
private static func drawActivityBadge(
type: WorkflowType,
systemName: String,
in bounds: CGRect,
frame: Int,
phase: ActivityPhase
) {
let badgeSize: CGFloat = 7.5
let badgeRect = CGRect(
x: bounds.maxX - badgeSize - 0.8,
y: bounds.minY + 0.8,
width: badgeSize,
height: badgeSize
)
let badgeOpacity: CGFloat
let haloOpacity: CGFloat
switch phase {
case .recording:
let values: [CGFloat]
switch type {
case .transcription, .localTranscription:
values = [0.74, 1.0, 0.82, 0.92]
case .textImprover:
values = [0.66, 0.84, 1.0, 0.8]
case .dampfAblassen:
values = [1.0, 0.76, 0.94, 0.68]
case .emojiText:
values = [0.8, 0.92, 0.7, 1.0]
}
badgeOpacity = values[frame % values.count]
haloOpacity = 0.14 + (CGFloat(frame % 4) * 0.04)
case .processing:
let values: [CGFloat]
switch type {
case .transcription, .localTranscription:
values = [0.58, 0.72, 0.9, 0.72]
case .textImprover:
values = [0.48, 0.68, 0.92, 0.84]
case .dampfAblassen:
values = [0.84, 0.62, 0.9, 0.56]
case .emojiText:
values = [0.54, 0.76, 0.88, 0.68]
}
badgeOpacity = values[frame % values.count]
haloOpacity = 0.12 + (CGFloat((frame + 2) % 4) * 0.03)
}
let haloInset = phase == .recording ? -0.8 : -0.45
let haloRect = badgeRect.insetBy(dx: haloInset, dy: haloInset)
let haloPath = NSBezierPath(ovalIn: haloRect)
NSColor.black.withAlphaComponent(haloOpacity).setStroke()
haloPath.lineWidth = phase == .recording ? 0.9 : 0.75
haloPath.stroke()
drawBadge(systemName: systemName, in: bounds, fillOpacity: badgeOpacity)
if phase == .processing {
drawProcessingDot(around: badgeRect, frame: frame)
}
}
private static func drawBadge(systemName: String, in bounds: CGRect, fillOpacity: CGFloat) {
let badgeSize: CGFloat = 7.5
let badgeRect = CGRect(
x: bounds.maxX - badgeSize - 0.8,
y: bounds.minY + 0.8,
width: badgeSize,
height: badgeSize
)
let badgePath = NSBezierPath(ovalIn: badgeRect)
NSColor.black.withAlphaComponent(fillOpacity).setFill()
badgePath.fill()
guard let symbol = NSImage(
systemSymbolName: systemName,
accessibilityDescription: nil
) else {
return
}
let config = NSImage.SymbolConfiguration(pointSize: 5.5, weight: .bold)
let configuredSymbol = symbol.withSymbolConfiguration(config) ?? symbol
let symbolRect = badgeRect.insetBy(dx: 1.2, dy: 1.2)
configuredSymbol.draw(
in: symbolRect,
from: .zero,
operation: .destinationOut,
fraction: 1.0
)
}
private static func drawProcessingDot(around badgeRect: CGRect, frame: Int) {
let orbitPoints: [CGPoint] = [
CGPoint(x: badgeRect.midX, y: badgeRect.maxY + 0.35),
CGPoint(x: badgeRect.maxX + 0.35, y: badgeRect.midY),
CGPoint(x: badgeRect.midX, y: badgeRect.minY - 0.35),
CGPoint(x: badgeRect.minX - 0.35, y: badgeRect.midY),
]
let point = orbitPoints[frame % orbitPoints.count]
let dotRect = CGRect(x: point.x - 0.85, y: point.y - 0.85, width: 1.7, height: 1.7)
let dotPath = NSBezierPath(ovalIn: dotRect)
NSColor.black.withAlphaComponent(0.92).setFill()
dotPath.fill()
}
private static func baseAlphaValues(for status: MenuBarStatus, frame: Int) -> [CGFloat] {
switch status {
case .idle:
return [1.0, 0.82, 0.64, 0.46]
case .recording(let type):
return recordingAlphaValues(for: type, frame: frame)
case .processing(let type):
return processingAlphaValues(for: type, frame: frame)
case .success:
return [1.0, 0.9, 0.78, 0.62]
case .error:
return [1.0, 0.7, 0.52, 0.36]
}
}
private static func recordingAlphaValues(for type: WorkflowType, frame: Int) -> [CGFloat] {
switch type {
case .transcription, .localTranscription:
let patterns: [[CGFloat]] = [
[1.0, 0.42, 0.28, 0.18],
[0.82, 1.0, 0.4, 0.24],
[0.58, 0.86, 1.0, 0.36],
[0.4, 0.62, 0.88, 1.0],
]
return patterns[frame % patterns.count]
case .textImprover:
let patterns: [[CGFloat]] = [
[1.0, 0.88, 0.52, 0.3],
[0.86, 1.0, 0.84, 0.44],
[0.64, 0.9, 1.0, 0.68],
[0.48, 0.68, 0.9, 1.0],
]
return patterns[frame % patterns.count]
case .dampfAblassen:
let patterns: [[CGFloat]] = [
[1.0, 0.44, 0.78, 1.0],
[0.86, 0.34, 0.96, 0.9],
[0.72, 0.3, 1.0, 0.78],
[0.94, 0.4, 0.74, 1.0],
]
return patterns[frame % patterns.count]
case .emojiText:
let patterns: [[CGFloat]] = [
[1.0, 0.7, 0.46, 0.28],
[0.78, 1.0, 0.72, 0.42],
[0.52, 0.82, 1.0, 0.66],
[0.36, 0.58, 0.84, 1.0],
]
return patterns[frame % patterns.count]
}
}
private static func processingAlphaValues(for type: WorkflowType, frame: Int) -> [CGFloat] {
switch type {
case .transcription, .localTranscription:
let patterns: [[CGFloat]] = [
[1.0, 0.84, 0.68, 0.52],
[0.92, 0.8, 0.64, 0.5],
[0.84, 0.74, 0.6, 0.48],
[0.92, 0.8, 0.64, 0.5],
]
return patterns[frame % patterns.count]
case .textImprover:
let patterns: [[CGFloat]] = [
[1.0, 0.76, 0.52, 0.34],
[0.86, 1.0, 0.74, 0.48],
[0.7, 0.88, 1.0, 0.72],
[0.56, 0.74, 0.9, 1.0],
]
return patterns[frame % patterns.count]
case .dampfAblassen:
let patterns: [[CGFloat]] = [
[0.9, 0.5, 0.72, 1.0],
[0.78, 0.44, 0.9, 1.0],
[0.66, 0.38, 1.0, 0.88],
[0.84, 0.48, 0.78, 1.0],
]
return patterns[frame % patterns.count]
case .emojiText:
let patterns: [[CGFloat]] = [
[1.0, 0.8, 0.58, 0.4],
[0.88, 1.0, 0.78, 0.54],
[0.74, 0.9, 1.0, 0.7],
[0.6, 0.76, 0.92, 1.0],
]
return patterns[frame % patterns.count]
}
}
private static func badgeSymbol(for type: WorkflowType) -> String {
switch type {
case .transcription:
return "mic.fill"
case .localTranscription:
return "lock.shield.fill"
case .textImprover:
return "text.alignleft"
case .dampfAblassen:
return "flame.fill"
case .emojiText:
return "face.smiling"
}
}
private static func baseTemplateImage() -> NSImage? {
guard let image = NSImage(named: "menubar_icon") else { return nil }
image.isTemplate = true
image.size = NSSize(width: 18, height: 18)
return image
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,137 @@
import SwiftUI
struct WorkflowRowView: View {
let type: WorkflowType
let enabled: Bool
var customName: String? = nil
var subtitle: String? = nil
let action: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
// Icon with monochrome background
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.primary.opacity(isHovered ? 0.1 : 0.06))
.frame(width: 36, height: 36)
Image(systemName: type.icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.secondary)
}
// Name + subtitle
VStack(alignment: .leading, spacing: 2) {
Text(customName ?? type.displayName)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(enabled ? .primary : .tertiary)
.lineLimit(1)
Text(subtitle ?? type.subtitle)
.font(.system(size: 11))
.foregroundStyle(enabled ? .secondary : .quaternary)
.lineLimit(1)
}
Spacer()
// Hotkey badge
HotkeyBadge(label: type.hotkeyLabel, enabled: enabled)
.opacity(enabled ? 1 : 0.4)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isHovered && enabled ? Color.primary.opacity(0.05) : Color.clear)
)
.padding(.horizontal, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.opacity(enabled ? 1 : 0.5)
.onHover { hovering in
withAnimation(.easeOut(duration: 0.12)) {
isHovered = hovering
}
}
}
}
// MARK: - Hotkey Badge
struct HotkeyBadge: View {
let label: String
let enabled: Bool
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 3) {
ForEach(label.components(separatedBy: " + "), id: \.self) { key in
Text(key)
.font(.system(size: 10.5, weight: .semibold, design: .rounded))
.foregroundStyle(keyTextColor)
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(keyBackgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(keyStrokeColor, lineWidth: 0.8)
)
.shadow(color: keyShadowColor, radius: 1.2, y: 0.6)
}
}
}
private var keyTextColor: Color {
guard enabled else {
return colorScheme == .dark
? Color.white.opacity(0.34)
: Color.black.opacity(0.26)
}
return colorScheme == .dark
? Color.white.opacity(0.84)
: Color.black.opacity(0.72)
}
private var keyBackgroundColor: Color {
guard enabled else {
return colorScheme == .dark
? Color.white.opacity(0.05)
: Color.black.opacity(0.035)
}
return colorScheme == .dark
? Color.white.opacity(0.12)
: Color.black.opacity(0.09)
}
private var keyStrokeColor: Color {
guard enabled else {
return colorScheme == .dark
? Color.white.opacity(0.08)
: Color.black.opacity(0.06)
}
return colorScheme == .dark
? Color.white.opacity(0.20)
: Color.black.opacity(0.16)
}
private var keyShadowColor: Color {
guard enabled else { return .clear }
return colorScheme == .dark
? Color.black.opacity(0.10)
: Color.black.opacity(0.06)
}
}

View File

@ -0,0 +1,837 @@
import SwiftUI
import AppKit
struct SettingsContentView: View {
@Bindable var appState: AppState
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
// Two-tab segmented picker
Picker("", selection: $selectedTab) {
Text("Anpassen").tag(0)
Text("Zugang").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.vertical, 10)
ScrollView {
if selectedTab == 0 {
CustomizeSettingsView(appState: appState)
} else {
AccessSettingsView(appState: appState)
}
}
}
.onAppear {
appState.refreshAccessibilityPermission()
selectedTab = defaultTabSelection
}
}
private var defaultTabSelection: Int {
if !appState.accessibilityPermissionGranted {
return 1
}
if appState.isConfigured && !BlitztextInstallLocationService.shouldOfferMoveToApplications {
return 0
}
return 1
}
}
// MARK: - Section Label (quiet style)
private struct SectionLabel: View {
let text: String
var body: some View {
Text(text.uppercased())
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
}
}
// MARK: - Access Settings (Tab 1: Zugang)
struct AccessSettingsView: View {
private static let openAIAPIKeyPattern = #"^sk-[A-Za-z0-9_-]{20,}$"#
@Bindable var appState: AppState
private enum FieldFocus {
case openAIAPIKey
}
@State private var launchAtLoginService = LaunchAtLoginService()
@State private var currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
@State private var openAIAPIKey = ""
@State private var editingAPIKey = false
@State private var saved = false
@State private var saveErrorText: String?
@State private var installActionErrorText: String?
@State private var showCleanupOptions = false
@State private var deleteLocalDataOnCleanup = true
@State private var cleanupStatusText: String?
@State private var cleanupErrorText: String?
@FocusState private var focusedField: FieldFocus?
var body: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Berechtigungen")
HStack(alignment: .top, spacing: 8) {
Image(systemName: appState.accessibilityPermissionGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(appState.accessibilityPermissionGranted ? .green : .orange)
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 3) {
Text(appState.accessibilityPermissionGranted ? "Direktes Einfügen ist freigegeben." : "Direktes Einfügen ist noch nicht freigegeben.")
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text("Öffne Bedienungshilfen und aktiviere Blitztext. Falls Blitztext schon aktiv ist, einmal aus- und wieder einschalten.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
HStack(spacing: 8) {
Button("Bedienungshilfen öffnen") {
appState.requestAccessibilityPermission()
}
.buttonStyle(SubtleButtonStyle())
Button("Erneut prüfen") {
appState.refreshAccessibilityPermission()
}
.buttonStyle(SubtleButtonStyle())
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
SectionLabel(text: "OpenAI API Key")
Spacer()
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
Button("Aendern") { editingAPIKey = true }
.font(.system(size: 10, weight: .medium))
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
}
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
HStack(spacing: 6) {
Image(systemName: "lock.fill")
.font(.system(size: 9))
.foregroundStyle(.green.opacity(0.8))
Text(appState.apiKeyDisplayValue(for: .openAIAPIKey))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .controlBackgroundColor))
)
} else {
HStack(spacing: 8) {
SecureField("sk-...", text: $openAIAPIKey)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11.5))
.focused($focusedField, equals: .openAIAPIKey)
Button("Einfuegen") {
pasteAPIKeyFromClipboard()
}
.buttonStyle(SubtleButtonStyle())
}
}
Text("Dein Key bleibt lokal in dieser App. Audio und Text werden direkt an die OpenAI API gesendet.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Installation")
Text(installationHeadline)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(.primary)
Text(installationDetail)
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(BlitztextInstallLocationService.bundleURL.path)
.font(.system(size: 10.5, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
Text("Weitere Blitztext-Kopien auf diesem Mac können doppelte Login-Items auslösen.")
.font(.system(size: 10.5))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
if BlitztextInstallLocationService.shouldOfferMoveToApplications {
Button("Nach /Applications bewegen") {
moveToApplications()
}
.buttonStyle(SubtleButtonStyle())
}
Button("Im Finder zeigen") {
revealInFinder(urls: [BlitztextInstallLocationService.bundleURL])
}
.buttonStyle(SubtleButtonStyle())
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
Button("Weitere Kopien zeigen") {
revealInFinder(urls: BlitztextInstallLocationService.otherInstalledBundleURLs)
}
.buttonStyle(SubtleButtonStyle())
}
}
if let installActionErrorText {
Text(installActionErrorText)
.font(.system(size: 10.5))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Updates")
Text("Diese Preview hat keinen oeffentlichen Update-Feed. Baue neue Versionen selbst aus dem Repo.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if !currentInstallLocation.isCanonicalInstall {
Text("Hotkeys und Login-Start laufen am stabilsten, wenn Blitztext aus /Applications gestartet wird.")
.font(.system(size: 10.5))
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("Updates sind in dieser Preview manuell: pull, build, starten.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
// Launch at Login
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Beim Anmelden")
Toggle("Blitztext automatisch starten", isOn: Binding(
get: { launchAtLoginService.isEnabled },
set: { launchAtLoginService.setEnabled($0) }
))
.toggleStyle(.switch)
Text(launchAtLoginService.errorText ?? launchAtLoginService.helperText)
.font(.system(size: 10.5))
.foregroundStyle(
launchAtLoginService.errorText == nil
? AnyShapeStyle(.secondary)
: AnyShapeStyle(.red)
)
.fixedSize(horizontal: false, vertical: true)
}
if let saveErrorText {
Text(saveErrorText)
.font(.system(size: 10.5))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: "Hinweis")
Text("Fuer direktes Einfuegen: Blitztext einmal nach /Applications legen und danach Mikrofon sowie Bedienungshilfen erlauben.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.primary.opacity(0.03))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 0.5)
)
}
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Sauber Entfernen")
Text("Vor dem Löschen Blitztext erst auf diesem Mac bereinigen. So verschwinden Anmeldestart und lokale Daten sauber aus dem Weg.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if showCleanupOptions {
Toggle("Zugangsdaten und Einstellungen dieses Macs löschen", isOn: $deleteLocalDataOnCleanup)
.toggleStyle(.switch)
Text("Danach Blitztext beenden und die App aus /Applications löschen. Bereits verwaiste alte Login-Items können in den Systemeinstellungen einmalig manuell entfernt werden.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Button("Abbrechen") {
showCleanupOptions = false
}
.buttonStyle(SubtleButtonStyle())
Button("Jetzt bereinigen") {
runCleanup()
}
.buttonStyle(SubtleButtonStyle())
.foregroundStyle(.red)
}
} else {
Button("Entfernung vorbereiten") {
showCleanupOptions = true
}
.buttonStyle(SubtleButtonStyle())
}
if let cleanupStatusText {
Text(cleanupStatusText)
.font(.system(size: 10.5))
.foregroundStyle(.green)
.fixedSize(horizontal: false, vertical: true)
}
if let cleanupErrorText {
Text(cleanupErrorText)
.font(.system(size: 10.5))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// Save button (right-aligned, text only)
HStack {
Spacer()
Button {
save()
} label: {
if saved {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
Text("Gespeichert")
}
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.green)
} else {
Text("Speichern")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.blue)
}
}
.buttonStyle(SubtleButtonStyle())
.animation(.easeInOut(duration: 0.2), value: saved)
}
}
.padding(16)
.onAppear {
launchAtLoginService.refresh()
refreshInstallState()
load()
if !appState.hasValue(for: .openAIAPIKey) {
editingAPIKey = true
focusedField = .openAIAPIKey
}
}
}
private func load() {
openAIAPIKey = ""
}
private func save() {
saveErrorText = nil
cleanupStatusText = nil
cleanupErrorText = nil
KeychainService.invalidateCache()
let trimmedAPIKey = openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
if editingAPIKey || !appState.hasValue(for: .openAIAPIKey) {
guard !trimmedAPIKey.isEmpty else {
saveErrorText = "Bitte trage deinen OpenAI API Key ein."
return
}
do {
try KeychainService.save(key: .openAIAPIKey, value: trimmedAPIKey)
openAIAPIKey = ""
editingAPIKey = false
} catch {
saveErrorText = "OpenAI API Key konnte nicht gespeichert werden."
return
}
}
KeychainService.invalidateCache()
if !appState.hasValue(for: .openAIAPIKey) {
saveErrorText = "OpenAI API Key wurde nicht persistent gespeichert. Bitte App neu starten und erneut versuchen."
return
}
withAnimation(.easeInOut(duration: 0.2)) { saved = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.2)) { saved = false }
}
}
private func pasteAPIKeyFromClipboard() {
guard let rawText = NSPasteboard.general.string(forType: .string) else {
saveErrorText = "Zwischenablage enthält keinen Text."
return
}
let firstLine = rawText.components(separatedBy: .newlines).first ?? rawText
let trimmedKey = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmedKey.range(of: Self.openAIAPIKeyPattern, options: .regularExpression) != nil else {
saveErrorText = "Zwischenablage enthält keinen plausiblen OpenAI API Key."
return
}
openAIAPIKey = trimmedKey
NSPasteboard.general.clearContents()
saveErrorText = nil
}
private var installationHeadline: String {
switch currentInstallLocation {
case .applications:
return "Blitztext liegt am richtigen Ort."
case .userApplications:
return "Blitztext liegt noch in ~/Applications."
case .outsideApplications:
return "Blitztext liegt noch nicht in /Applications."
case .unknown:
return "Der Installationsort konnte nicht sicher erkannt werden."
}
}
private var installationDetail: String {
switch currentInstallLocation {
case .applications:
if BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
return "Für stabile Login-Items und Updates nur diese Kopie weiterverwenden."
}
return "Diese Kopie ist korrekt. Zusätzliche Kopien solltest du später entfernen."
case .userApplications:
return "Fuer stabile Hotkeys und Login-Items sollte Blitztext nur aus /Applications laufen."
case .outsideApplications:
return "Verschiebe Blitztext einmal nach /Applications, damit Anmeldestart und Hotkeys sauber bleiben."
case .unknown:
return "Öffne Blitztext möglichst direkt aus /Applications."
}
}
private func refreshInstallState() {
currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
installActionErrorText = nil
}
private func moveToApplications() {
installActionErrorText = nil
do {
try BlitztextInstallLocationService.moveToApplicationsAndRelaunch()
} catch {
installActionErrorText = error.localizedDescription
}
}
private func runCleanup() {
cleanupStatusText = nil
cleanupErrorText = nil
let report = deleteLocalDataOnCleanup
? BlitztextCleanupService.cleanupUserData()
: BlitztextCleanupService.removeLaunchAtLoginRegistration()
KeychainService.invalidateCache()
launchAtLoginService.refresh()
refreshInstallState()
if deleteLocalDataOnCleanup {
openAIAPIKey = ""
editingAPIKey = true
}
if report.failedItems.isEmpty {
cleanupStatusText = deleteLocalDataOnCleanup
? "Anmeldestart und lokale Daten wurden bereinigt. Jetzt Blitztext beenden und aus /Applications löschen."
: "Anmeldestart wurde deaktiviert. Jetzt Blitztext beenden und aus /Applications löschen."
showCleanupOptions = false
let urlsToReveal = report.knownInstallBundleURLs.isEmpty
? [BlitztextInstallLocationService.bundleURL]
: report.knownInstallBundleURLs
revealInFinder(urls: urlsToReveal)
return
}
let failureSummary = report.failedItems
.map { "\($0.url.lastPathComponent): \($0.errorDescription)" }
.joined(separator: "\n")
cleanupErrorText = "Nicht alles konnte bereinigt werden:\n\(failureSummary)"
}
private func revealInFinder(urls: [URL]) {
guard !urls.isEmpty else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
}
}
// MARK: - Customize Settings (Tab 2: Anpassen)
struct CustomizeSettingsView: View {
@Bindable var appState: AppState
@State private var newTerm = ""
private var installedLocalModels: [LocalTranscriptionModel] {
LocalTranscriptionService.installedModels()
}
private var localModelOptions: [LocalTranscriptionModel] {
LocalTranscriptionService.modelOptions()
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// MARK: Lokaler Modus
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Sicherer Lokaler Modus")
Toggle("Sicherer Lokaler Modus", isOn: $appState.appSettings.secureLocalModeEnabled)
.toggleStyle(.switch)
.onChange(of: appState.appSettings.secureLocalModeEnabled) { _, newValue in
if newValue && !appState.selectedLocalModelIsInstalled {
appState.installSelectedLocalModel()
}
}
HStack(spacing: 6) {
Image(systemName: appState.selectedLocalModelIsInstalled ? "checkmark.circle.fill" : "arrow.down.circle.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(appState.selectedLocalModelIsInstalled ? .green : .blue)
Text(appState.selectedLocalModelIsInstalled ? "\(installedLocalModels.count) lokales WhisperKit-Modell installiert." : "Das ausgewählte Modell wird beim Installieren lokal gespeichert.")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
Spacer()
}
HStack(spacing: 8) {
Text("Lokales Modell")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Picker("", selection: Binding(
get: { appState.selectedLocalModelName },
set: { appState.appSettings.selectedLocalTranscriptionModelName = $0 }
)) {
ForEach(localModelOptions) { model in
Text("\(model.displayName) · \(model.installStateLabel)").tag(model.id)
}
}
.labelsHidden()
.controlSize(.small)
.disabled(appState.isDownloadingLocalModel)
}
if let progress = appState.localModelDownloadProgress {
VStack(alignment: .leading, spacing: 4) {
ProgressView(value: progress)
Text(appState.localModelDownloadStatusText ?? "Modell wird geladen...")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
} else {
HStack(spacing: 10) {
Button(appState.localModelDownloadButtonTitle) {
appState.installSelectedLocalModel()
}
.controlSize(.small)
.disabled(appState.selectedLocalModelIsInstalled)
Link("Modellseite", destination: LocalTranscriptionService.modelPageURL(for: appState.selectedLocalModelName))
.font(.system(size: 10.5, weight: .medium))
}
}
if let errorText = appState.localModelDownloadErrorText {
Text(errorText)
.font(.system(size: 10.5))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: Tastenkuerzel
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Tastenk\u{00FC}rzel")
VStack(spacing: 6) {
ForEach(WorkflowType.mainMenuCases) { type in
HStack {
Text(type.hotkeyLabel)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 124, alignment: .leading)
Text(appState.displayName(for: type))
.font(.system(size: 11.5, weight: .medium))
Spacer()
}
}
}
// Mode picker
VStack(alignment: .leading, spacing: 8) {
Text("Modus")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Picker("", selection: $appState.appSettings.hotkeyMode) {
ForEach(HotkeyMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
}
}
// MARK: Blitztext+
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Blitztext+")
// Tone
VStack(alignment: .leading, spacing: 8) {
Text("Schreibstil")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Picker("", selection: $appState.textImprovementSettings.tone) {
ForEach(TextImprovementSettings.TextTone.allCases) { tone in
Text(tone.displayName).tag(tone)
}
}
.pickerStyle(.segmented)
}
// System Prompt
VStack(alignment: .leading, spacing: 8) {
Text("Eigene Anweisung")
.font(.system(size: 11))
.foregroundStyle(.secondary)
TextEditor(text: $appState.textImprovementSettings.systemPrompt)
.font(.system(size: 11))
.frame(height: 64)
.scrollContentBackground(.hidden)
.padding(8)
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
.overlay(alignment: .topLeading) {
if appState.textImprovementSettings.systemPrompt.isEmpty {
Text("z.B. \"Schreibe pr\u{00E4}gnant und ohne F\u{00FC}llw\u{00F6}rter.\"")
.font(.system(size: 11))
.foregroundStyle(.quaternary)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.allowsHitTesting(false)
}
}
}
// Context
VStack(alignment: .leading, spacing: 8) {
Text("Kontext")
.font(.system(size: 11))
.foregroundStyle(.secondary)
TextField("z.B. \"E-Mails im Bereich Unternehmensberatung\"", text: $appState.textImprovementSettings.context)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
}
}
// MARK: Blitztext $%&!
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Blitztext $%&!")
VStack(alignment: .leading, spacing: 8) {
Text("Eigene Anweisung")
.font(.system(size: 11))
.foregroundStyle(.secondary)
TextEditor(text: $appState.dampfAblassenSettings.systemPrompt)
.font(.system(size: 11))
.frame(height: 80)
.scrollContentBackground(.hidden)
.padding(8)
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
.overlay(alignment: .topLeading) {
if appState.dampfAblassenSettings.systemPrompt.isEmpty {
Text("z.B. \"Formuliere den Text sachlich und freundlich um.\"")
.font(.system(size: 11))
.foregroundStyle(.quaternary)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.allowsHitTesting(false)
}
}
}
}
// MARK: Blitztext :)
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Blitztext :)")
VStack(alignment: .leading, spacing: 8) {
Text("Emoji-Dichte")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Picker("", selection: $appState.emojiTextSettings.emojiDensity) {
ForEach(EmojiTextSettings.EmojiDensity.allCases) { density in
Text(density.displayName).tag(density)
}
}
.pickerStyle(.segmented)
}
}
// MARK: Eigennamen
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: "Eigennamen")
// Term chips
if !appState.textImprovementSettings.customTerms.isEmpty {
FlowLayout(spacing: 5) {
ForEach(appState.textImprovementSettings.customTerms, id: \.self) { term in
HStack(spacing: 3) {
Text(term)
.font(.system(size: 10.5))
Button {
withAnimation(.easeOut(duration: 0.15)) {
appState.textImprovementSettings.customTerms.removeAll { $0 == term }
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.tertiary)
}
.buttonStyle(SubtleButtonStyle())
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
Capsule()
.strokeBorder(Color.primary.opacity(0.04), lineWidth: 0.5)
)
}
}
}
HStack(spacing: 6) {
TextField("Neuer Begriff", text: $newTerm)
.textFieldStyle(.roundedBorder)
.font(.system(size: 11))
.onSubmit { addTerm() }
Button { addTerm() } label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.blue.opacity(0.7))
}
.buttonStyle(SubtleButtonStyle())
.disabled(newTerm.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.padding(16)
}
private func addTerm() {
let trimmed = newTerm.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !appState.textImprovementSettings.customTerms.contains(trimmed) else { return }
withAnimation(.easeOut(duration: 0.15)) {
appState.textImprovementSettings.customTerms.append(trimmed)
}
newTerm = ""
}
}
// MARK: - Flow Layout (for term tags)
struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
}
}
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var maxX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
positions.append(CGPoint(x: x, y: y))
rowHeight = max(rowHeight, size.height)
x += size.width + spacing
maxX = max(maxX, x)
}
return (positions, CGSize(width: maxX, height: y + rowHeight))
}
}

View File

@ -0,0 +1,118 @@
import Foundation
import AppKit
import Observation
@Observable
@MainActor
final class DampfAblassenWorkflow: Workflow {
let type = WorkflowType.dampfAblassen
var phase: WorkflowPhase = .idle {
didSet { onPhaseChange?(phase) }
}
var onOutput: WorkflowOutputHandler?
var onPhaseChange: WorkflowPhaseChangeHandler?
private let recorder = AudioRecorder()
private let settings: DampfAblassenSettings
private let customTerms: [String]
private let language: String
private var processingTask: Task<Void, Never>?
init(settings: DampfAblassenSettings, customTerms: [String] = [], language: String = "de") {
self.settings = settings
self.customTerms = customTerms
self.language = language
}
// MARK: - Recording State
var isRecording: Bool { recorder.isRecording }
var audioLevel: Float { recorder.audioLevel }
// MARK: - Workflow Protocol
func start() {
phase = .running("Aufnahme läuft ...")
recorder.startRecording()
if let error = recorder.errorMessage {
phase = .error(error)
}
}
func stop() {
if recorder.isRecording {
recorder.stopRecording()
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
recorder.discardRecording()
phase = .error("Keine Aufnahme erkannt.")
return
}
processRecording()
} else {
processingTask?.cancel()
phase = .idle
}
}
func reset() {
processingTask?.cancel()
if recorder.isRecording {
recorder.stopRecording()
}
recorder.discardRecording()
phase = .idle
}
// MARK: - Two-Phase Processing: Whisper -> GPT Rage Mode
private func processRecording() {
guard let url = recorder.recordingURL else {
phase = .error("Keine Aufnahme vorhanden.")
return
}
phase = .running("Wird transkribiert ...")
let recordingDuration = recorder.lastRecordingDuration
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
processingTask = Task {
defer {
try? FileManager.default.removeItem(at: url)
}
do {
// Phase 1: Whisper transcription
let rawText = try await TranscriptionService.transcribe(
audioURL: url,
customTerms: vocabularyHints,
language: language
)
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
phase = .error("Keine Aufnahme erkannt.")
return
}
if Task.isCancelled { return }
// Phase 2: GPT dampf ablassen
phase = .running("Wird umformuliert ...")
let answer = try await LLMService.dampfAblassen(
text: cleanedRawText,
systemPrompt: settings.systemPrompt
)
let cleanedAnswer = TranscriptionQualityService.cleanedTranscript(answer)
guard cleanedAnswer != "KEINE_AUFNAHME_ERKANNT" else {
phase = .error("Keine Aufnahme erkannt.")
return
}
phase = .done(cleanedAnswer)
onOutput?(cleanedAnswer)
} catch {
phase = .error(error.localizedDescription)
}
}
}
}

View File

@ -0,0 +1,118 @@
import Foundation
import AppKit
import Observation
@Observable
@MainActor
final class EmojiTextWorkflow: Workflow {
let type = WorkflowType.emojiText
var phase: WorkflowPhase = .idle {
didSet { onPhaseChange?(phase) }
}
var onOutput: WorkflowOutputHandler?
var onPhaseChange: WorkflowPhaseChangeHandler?
private let recorder = AudioRecorder()
private let settings: EmojiTextSettings
private let customTerms: [String]
private let language: String
private var processingTask: Task<Void, Never>?
init(settings: EmojiTextSettings, customTerms: [String] = [], language: String = "de") {
self.settings = settings
self.customTerms = customTerms
self.language = language
}
// MARK: - Recording State
var isRecording: Bool { recorder.isRecording }
var audioLevel: Float { recorder.audioLevel }
// MARK: - Workflow Protocol
func start() {
phase = .running("Aufnahme l\u{00E4}uft ...")
recorder.startRecording()
if let error = recorder.errorMessage {
phase = .error(error)
}
}
func stop() {
if recorder.isRecording {
recorder.stopRecording()
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
recorder.discardRecording()
phase = .error("Keine Aufnahme erkannt.")
return
}
processRecording()
} else {
processingTask?.cancel()
phase = .idle
}
}
func reset() {
processingTask?.cancel()
if recorder.isRecording {
recorder.stopRecording()
}
recorder.discardRecording()
phase = .idle
}
// MARK: - Two-Phase Processing: Whisper -> Emoji
private func processRecording() {
guard let url = recorder.recordingURL else {
phase = .error("Keine Aufnahme vorhanden.")
return
}
phase = .running("Wird transkribiert ...")
let recordingDuration = recorder.lastRecordingDuration
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
processingTask = Task {
defer {
try? FileManager.default.removeItem(at: url)
}
do {
// Phase 1: Whisper transcription
let rawText = try await TranscriptionService.transcribe(
audioURL: url,
customTerms: vocabularyHints,
language: language
)
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
phase = .error("Keine Aufnahme erkannt.")
return
}
if Task.isCancelled { return }
// Phase 2: Add emojis
phase = .running("Emojis werden eingef\u{00FC}gt ...")
let result = try await LLMService.addEmojis(
text: cleanedRawText,
settings: settings
)
let cleanedResult = TranscriptionQualityService.cleanedTranscript(result)
guard cleanedResult != "KEINE_AUFNAHME_ERKANNT" else {
phase = .error("Keine Aufnahme erkannt.")
return
}
phase = .done(cleanedResult)
onOutput?(cleanedResult)
} catch {
phase = .error(error.localizedDescription)
}
}
}
}

View File

@ -0,0 +1,113 @@
import Foundation
import AppKit
import Observation
@Observable
@MainActor
final class TextImprovementWorkflow: Workflow {
let type = WorkflowType.textImprover
var phase: WorkflowPhase = .idle {
didSet { onPhaseChange?(phase) }
}
var onOutput: WorkflowOutputHandler?
var onPhaseChange: WorkflowPhaseChangeHandler?
private let recorder = AudioRecorder()
private let settings: TextImprovementSettings
private let language: String
private var processingTask: Task<Void, Never>?
init(settings: TextImprovementSettings, language: String = "de") {
self.settings = settings
self.language = language
}
// MARK: - Recording State
var isRecording: Bool { recorder.isRecording }
var audioLevel: Float { recorder.audioLevel }
// MARK: - Workflow Protocol
func start() {
phase = .running("Aufnahme läuft ...")
recorder.startRecording()
if let error = recorder.errorMessage {
phase = .error(error)
}
}
func stop() {
if recorder.isRecording {
recorder.stopRecording()
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
recorder.discardRecording()
phase = .error("Keine Aufnahme erkannt.")
return
}
processRecording()
} else {
processingTask?.cancel()
phase = .idle
}
}
func reset() {
processingTask?.cancel()
if recorder.isRecording {
recorder.stopRecording()
}
recorder.discardRecording()
phase = .idle
}
// MARK: - Two-Phase Processing: Whisper -> GPT
private func processRecording() {
guard let url = recorder.recordingURL else {
phase = .error("Keine Aufnahme vorhanden.")
return
}
phase = .running("Wird transkribiert ...")
let recordingDuration = recorder.lastRecordingDuration
let vocabularyHints = recordingDuration >= 0.9 ? settings.customTerms : []
processingTask = Task {
defer {
try? FileManager.default.removeItem(at: url)
}
do {
// Phase 1: Whisper transcription
let rawText = try await TranscriptionService.transcribe(
audioURL: url,
customTerms: vocabularyHints,
language: language
)
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
phase = .error("Keine Aufnahme erkannt.")
return
}
if Task.isCancelled { return }
// Phase 2: GPT improvement
phase = .running("Text wird verbessert ...")
let improved = try await LLMService.improve(
text: cleanedRawText,
settings: settings
)
let cleanedImproved = TranscriptionQualityService.cleanedTranscript(improved)
phase = .done(cleanedImproved)
onOutput?(cleanedImproved)
} catch {
phase = .error(error.localizedDescription)
}
}
}
}

View File

@ -0,0 +1,138 @@
import Foundation
import AppKit
import Observation
import OSLog
private let transcriptionLogger = Logger(subsystem: "app.blitztext.mac", category: "Transcription")
private func elapsedMilliseconds(since start: Date, until end: Date = Date()) -> Int {
Int((end.timeIntervalSince(start) * 1000).rounded())
}
@Observable
@MainActor
final class TranscriptionWorkflow: Workflow {
let type: WorkflowType
var phase: WorkflowPhase = .idle {
didSet { onPhaseChange?(phase) }
}
var onOutput: WorkflowOutputHandler?
var onPhaseChange: WorkflowPhaseChangeHandler?
private let recorder = AudioRecorder()
private let customTerms: [String]
private let language: String
private let backend: TranscriptionBackend
private let localModelName: String
private var transcriptionTask: Task<Void, Never>?
init(
type: WorkflowType = .transcription,
customTerms: [String] = [],
language: String = "de",
backend: TranscriptionBackend = .remote,
localModelName: String = LocalTranscriptionService.recommendedFastModelName
) {
self.type = type
self.customTerms = customTerms
self.language = language
self.backend = backend
self.localModelName = localModelName
}
func start() {
phase = .running("Aufnahme läuft ...")
recorder.startRecording()
if let error = recorder.errorMessage {
phase = .error(error)
}
}
func stop() {
if recorder.isRecording {
recorder.stopRecording()
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
recorder.discardRecording()
phase = .error("Keine Aufnahme erkannt.")
return
}
transcribe()
} else {
transcriptionTask?.cancel()
phase = .idle
}
}
func reset() {
transcriptionTask?.cancel()
if recorder.isRecording {
recorder.stopRecording()
}
recorder.discardRecording()
phase = .idle
}
var isRecording: Bool { recorder.isRecording }
var audioLevel: Float { recorder.audioLevel }
private func transcribe() {
guard let url = recorder.recordingURL else {
phase = .error("Keine Aufnahme vorhanden.")
return
}
phase = .running(backend == .local ? "Wird lokal transkribiert ..." : "Wird transkribiert ...")
let recordingDuration = recorder.lastRecordingDuration
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
let requestLanguage = language
let stopTime = Date()
transcriptionTask = Task(priority: .userInitiated) {
defer {
try? FileManager.default.removeItem(at: url)
}
let requestStart = Date()
do {
let text: String
switch backend {
case .remote:
text = try await TranscriptionService.transcribe(
audioURL: url,
customTerms: vocabularyHints,
language: requestLanguage
)
case .local:
text = try await LocalTranscriptionService.shared.transcribe(
audioURL: url,
language: requestLanguage,
modelName: localModelName
)
}
try Task.checkCancellation()
let responseReceivedAt = Date()
let cleaned = TranscriptionQualityService.cleanedTranscript(text)
guard !TranscriptionQualityService.isLikelyArtifact(cleaned, recordingDuration: recordingDuration) else {
transcriptionLogger.info(
"Transcription rejected short artifact after \(elapsedMilliseconds(since: stopTime)) ms"
)
phase = .error("Keine Aufnahme erkannt.")
return
}
transcriptionLogger.info(
"Transcription ready in \(elapsedMilliseconds(since: stopTime, until: responseReceivedAt)) ms (request \(elapsedMilliseconds(since: requestStart, until: responseReceivedAt)) ms)"
)
phase = .done(cleaned)
onOutput?(cleaned)
} catch {
transcriptionLogger.error(
"Transcription failed after \(elapsedMilliseconds(since: stopTime)) ms: \(error.localizedDescription, privacy: .private)"
)
phase = .error(error.localizedDescription)
}
}
}
}

View File

@ -0,0 +1,223 @@
import Foundation
// MARK: - Workflow Types
enum WorkflowType: String, CaseIterable, Identifiable, Codable {
case transcription
case localTranscription
case textImprover
case dampfAblassen
case emojiText
var id: String { rawValue }
static var mainMenuCases: [WorkflowType] {
allCases.filter { $0 != .localTranscription }
}
var displayName: String {
switch self {
case .transcription: return "Blitztext"
case .localTranscription: return "Blitztext Lokal"
case .textImprover: return "Blitztext+"
case .dampfAblassen: return "Blitztext $%&!"
case .emojiText: return "Blitztext :)"
}
}
var icon: String {
switch self {
case .transcription: return "mic.fill"
case .localTranscription: return "lock.shield.fill"
case .textImprover: return "text.badge.checkmark"
case .dampfAblassen: return "flame.fill"
case .emojiText: return "face.smiling"
}
}
var subtitle: String {
switch self {
case .transcription: return "Sprache rein. Text raus."
case .localTranscription: return "Nur lokal. Kein Server."
case .textImprover: return "Geschrieben sprechen."
case .dampfAblassen: return "Frust rein. Entspannt raus."
case .emojiText: return "Text rein. Emojis dazu."
}
}
var hotkeyLabel: String {
switch self {
case .transcription: return "fn + Shift"
case .localTranscription: return "fn + Shift + Ctrl"
case .textImprover: return "fn + Control"
case .dampfAblassen: return "fn + Option"
case .emojiText: return "fn + Cmd"
}
}
var accentColor: String {
switch self {
case .transcription: return "blue"
case .localTranscription: return "green"
case .textImprover: return "purple"
case .dampfAblassen: return "orange"
case .emojiText: return "cyan"
}
}
}
// MARK: - Workflow State
enum WorkflowPhase: Equatable {
case idle
case running(String)
case done(String)
case error(String)
var isActive: Bool {
switch self {
case .idle: return false
default: return true
}
}
}
enum WorkflowLaunchSource: Equatable {
case manual
case hotkeyBackground
var presentsWorkflowPage: Bool {
switch self {
case .manual:
return true
case .hotkeyBackground:
return false
}
}
}
typealias WorkflowOutputHandler = @MainActor (String) -> Void
typealias WorkflowPhaseChangeHandler = @MainActor (WorkflowPhase) -> Void
// MARK: - Workflow Protocol
@MainActor
protocol Workflow: AnyObject, Observable {
var type: WorkflowType { get }
var phase: WorkflowPhase { get set }
var isRecording: Bool { get }
var onOutput: WorkflowOutputHandler? { get set }
var onPhaseChange: WorkflowPhaseChangeHandler? { get set }
func start()
func stop()
func reset()
}
// MARK: - App Settings
struct AppSettings: Codable {
var hotkeyMode: HotkeyMode = .hold
var hasSeenOnboarding: Bool = false
var secureLocalModeEnabled: Bool = false
var selectedLocalTranscriptionModelName: String = LocalTranscriptionService.recommendedFastModelName
var hasAutoSelectedFastLocalModel: Bool = false
init(
hotkeyMode: HotkeyMode = .hold,
hasSeenOnboarding: Bool = false,
secureLocalModeEnabled: Bool = false,
selectedLocalTranscriptionModelName: String = LocalTranscriptionService.recommendedFastModelName,
hasAutoSelectedFastLocalModel: Bool = false
) {
self.hotkeyMode = hotkeyMode
self.hasSeenOnboarding = hasSeenOnboarding
self.secureLocalModeEnabled = secureLocalModeEnabled
self.selectedLocalTranscriptionModelName = selectedLocalTranscriptionModelName
self.hasAutoSelectedFastLocalModel = hasAutoSelectedFastLocalModel
}
enum CodingKeys: String, CodingKey {
case hotkeyMode
case hasSeenOnboarding
case secureLocalModeEnabled
case selectedLocalTranscriptionModelName
case hasAutoSelectedFastLocalModel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
hotkeyMode = try container.decodeIfPresent(HotkeyMode.self, forKey: .hotkeyMode) ?? .hold
hasSeenOnboarding = try container.decodeIfPresent(Bool.self, forKey: .hasSeenOnboarding) ?? false
secureLocalModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .secureLocalModeEnabled) ?? false
selectedLocalTranscriptionModelName = try container.decodeIfPresent(
String.self,
forKey: .selectedLocalTranscriptionModelName
) ?? LocalTranscriptionService.recommendedFastModelName
hasAutoSelectedFastLocalModel = try container.decodeIfPresent(
Bool.self,
forKey: .hasAutoSelectedFastLocalModel
) ?? false
}
}
enum TranscriptionBackend: String, Codable {
case remote
case local
}
// MARK: - Workflow Settings
struct TranscriptionSettings: Codable {
var language: String = "de"
}
struct DampfAblassenSettings: Codable {
var systemPrompt: String = "Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche Ziel, Anliegen und den wahren Frust der Person. Formuliere daraus eine klare, respektvolle und wirksame Nachricht, mit der die Person ihr Ziel eher erreicht. Bewahre relevante Fakten, konkrete Probleme, Grenzen, Erwartungen und die nötige Dringlichkeit. Entferne Beleidigungen, Drohungen, Sarkasmus, Unterstellungen und unnötige Eskalation. Wenn mehrere Vorwürfe genannt werden, verdichte sie auf die entscheidenden Kernpunkte. Der Ton soll ruhig, menschlich, bestimmt und lösungsorientiert sein. Gib NUR die fertige Nachricht zurück."
var customName: String = ""
}
struct EmojiTextSettings: Codable {
var emojiDensity: EmojiDensity = .mittel
var customName: String = ""
enum EmojiDensity: String, Codable, CaseIterable, Identifiable {
case wenig
case mittel
case viel
var id: String { rawValue }
var displayName: String {
switch self {
case .wenig: return "Wenig"
case .mittel: return "Mittel"
case .viel: return "Viel"
}
}
}
}
struct TextImprovementSettings: Codable {
var systemPrompt: String = ""
var customTerms: [String] = []
var context: String = ""
var tone: TextTone = .neutral
var customName: String = ""
enum TextTone: String, Codable, CaseIterable, Identifiable {
case formal
case neutral
case casual
var id: String { rawValue }
var displayName: String {
switch self {
case .formal: return "Formell"
case .neutral: return "Neutral"
case .casual: return "Locker"
}
}
}
}

Binary file not shown.

View File

@ -0,0 +1,80 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_64x64.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "64x64"
},
{
"filename" : "icon_64x64@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "64x64"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,28 @@
<?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>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>NSMicrophoneUsageDescription</key>
<string>Blitztext benötigt Mikrofon-Zugriff für die Sprach-Transkription.</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

View File

@ -0,0 +1,34 @@
import AppKit
import ApplicationServices
@MainActor
enum AccessibilityPermissionService {
private static var hasPromptedThisSession = false
static func currentStatus() -> Bool {
AXIsProcessTrusted()
}
static func isTrusted(promptIfNeeded: Bool) -> Bool {
let shouldPrompt = promptIfNeeded && !hasPromptedThisSession
if shouldPrompt {
hasPromptedThisSession = true
}
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: shouldPrompt] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
static func requestPermissionPrompt() -> Bool {
hasPromptedThisSession = true
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
static func openSystemSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else {
return
}
NSWorkspace.shared.open(url)
}
}

View File

@ -0,0 +1,57 @@
import Foundation
enum AppSupportPaths {
private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "app.blitztext.mac"
static var appSupportDirectoryURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("Blitztext", isDirectory: true)
}
static var settingsURL: URL {
appSupportDirectoryURL.appendingPathComponent("settings.json")
}
static var localModelsDirectoryURL: URL {
appSupportDirectoryURL.appendingPathComponent("models", isDirectory: true)
}
static var whisperKitModelsDirectoryURL: URL {
localModelsDirectoryURL.appendingPathComponent("whisperkit", isDirectory: true)
}
static var defaultWhisperKitModelURL: URL {
whisperKitModelsDirectoryURL.appendingPathComponent(
"openai_whisper-large-v3-v20240930_626MB",
isDirectory: true
)
}
static var cachesDirectoryURL: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first!
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
static var preferencesURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Preferences", isDirectory: true)
.appendingPathComponent("\(bundleIdentifier).plist")
}
static var savedApplicationStateDirectoryURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Saved Application State", isDirectory: true)
.appendingPathComponent("\(bundleIdentifier).savedState", isDirectory: true)
}
static func ensureAppSupportDirectoryExists() throws {
try FileManager.default.createDirectory(
at: appSupportDirectoryURL,
withIntermediateDirectories: true
)
}
}

View File

@ -0,0 +1,98 @@
import AVFoundation
import Observation
@Observable
final class AudioRecorder: NSObject, AVAudioRecorderDelegate {
var isRecording = false
var recordingURL: URL?
var errorMessage: String?
var audioLevel: Float = 0
var lastRecordingDuration: TimeInterval = 0
private var audioRecorder: AVAudioRecorder?
private var levelTimer: Timer?
private var currentFileURL: URL?
private func makeRecordingURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("blitztext-\(UUID().uuidString).m4a")
}
func startRecording() {
errorMessage = nil
lastRecordingDuration = 0
recordingURL = nil
if let currentFileURL {
try? FileManager.default.removeItem(at: currentFileURL)
}
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 16000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
]
do {
let fileURL = makeRecordingURL()
currentFileURL = fileURL
audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
isRecording = true
startMetering()
} catch {
currentFileURL = nil
errorMessage = "Aufnahme konnte nicht gestartet werden: \(error.localizedDescription)"
}
}
func stopRecording() {
stopMetering()
lastRecordingDuration = audioRecorder?.currentTime ?? 0
audioRecorder?.stop()
isRecording = false
recordingURL = currentFileURL
currentFileURL = nil
audioRecorder = nil
audioLevel = 0
}
func discardRecording() {
if let recordingURL {
try? FileManager.default.removeItem(at: recordingURL)
self.recordingURL = nil
}
if let currentFileURL {
try? FileManager.default.removeItem(at: currentFileURL)
self.currentFileURL = nil
}
}
private func startMetering() {
levelTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
guard let self else { return }
self.audioRecorder?.updateMeters()
let power = self.audioRecorder?.averagePower(forChannel: 0) ?? -160
let normalized = max(0, min(1, (power + 50) / 50))
self.audioLevel = normalized
}
}
private func stopMetering() {
levelTimer?.invalidate()
levelTimer = nil
}
// MARK: - AVAudioRecorderDelegate
nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
if !flag {
Task { @MainActor in
self.errorMessage = "Aufnahme fehlgeschlagen"
}
}
}
}

View File

@ -0,0 +1,108 @@
import Foundation
import ServiceManagement
enum BlitztextCleanupService {
struct CleanupItemFailure: Identifiable, Equatable {
let id = UUID()
let url: URL
let errorDescription: String
}
struct CleanupReport: Equatable {
let removedURLs: [URL]
let failedItems: [CleanupItemFailure]
let knownInstallBundleURLs: [URL]
var didSucceedFully: Bool {
failedItems.isEmpty
}
}
static func cleanupUserData() -> CleanupReport {
KeychainService.delete(key: .openAIAPIKey)
return cleanup(paths: [
AppSupportPaths.settingsURL,
AppSupportPaths.appSupportDirectoryURL,
AppSupportPaths.cachesDirectoryURL,
AppSupportPaths.preferencesURL,
AppSupportPaths.savedApplicationStateDirectoryURL
], unregisterLaunchAtLogin: true)
}
static func removeLaunchAtLoginRegistration() -> CleanupReport {
cleanup(paths: [], unregisterLaunchAtLogin: true)
}
static func removeApplicationSupportFiles() -> CleanupReport {
KeychainService.delete(key: .openAIAPIKey)
return cleanup(
paths: [
AppSupportPaths.settingsURL,
AppSupportPaths.appSupportDirectoryURL
],
unregisterLaunchAtLogin: false
)
}
static func removeCacheAndStateFiles() -> CleanupReport {
cleanup(
paths: [
AppSupportPaths.cachesDirectoryURL,
AppSupportPaths.preferencesURL,
AppSupportPaths.savedApplicationStateDirectoryURL
],
unregisterLaunchAtLogin: false
)
}
static func knownInstallBundleURLs() -> [URL] {
BlitztextInstallLocationService.knownInstallBundleURLs
}
static func cleanup(paths: [URL], unregisterLaunchAtLogin: Bool) -> CleanupReport {
var removedURLs: [URL] = []
var failedItems: [CleanupItemFailure] = []
if unregisterLaunchAtLogin {
do {
try SMAppService.mainApp.unregister()
} catch {
failedItems.append(
CleanupItemFailure(
url: BlitztextInstallLocationService.bundleURL,
errorDescription: error.localizedDescription
)
)
}
}
for url in paths {
do {
try removeItemIfNeeded(at: url)
removedURLs.append(url)
} catch {
failedItems.append(
CleanupItemFailure(
url: url,
errorDescription: error.localizedDescription
)
)
}
}
return CleanupReport(
removedURLs: removedURLs,
failedItems: failedItems,
knownInstallBundleURLs: BlitztextInstallLocationService.otherInstalledBundleURLs
)
}
private static func removeItemIfNeeded(at url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else {
return
}
try FileManager.default.removeItem(at: url)
}
}

View File

@ -0,0 +1,176 @@
import AppKit
import Foundation
enum BlitztextInstallLocationService {
enum InstallLocation: Equatable {
case applications
case userApplications
case outsideApplications(URL)
case unknown
var isInApplicationsFolder: Bool {
switch self {
case .applications, .userApplications:
return true
case .outsideApplications, .unknown:
return false
}
}
var isCanonicalInstall: Bool {
if case .applications = self {
return true
}
return false
}
}
enum MoveError: LocalizedError {
case sourceBundleMissing(URL)
case destinationUnavailable
case destinationExists(URL)
case destinationNotWritable(URL)
case copyFailed(source: URL, destination: URL, underlying: Error)
var errorDescription: String? {
switch self {
case .sourceBundleMissing:
return "Die aktuelle App-Installation wurde nicht gefunden."
case .destinationUnavailable:
return "Der Zielordner /Applications ist nicht verfügbar."
case .destinationExists:
return "Am Zielort liegt bereits eine Blitztext-Installation."
case .destinationNotWritable:
return "Der Zielordner /Applications ist auf diesem Mac nicht beschreibbar."
case .copyFailed:
return "Blitztext konnte nicht nach /Applications kopiert werden."
}
}
}
static var bundleURL: URL {
Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
}
static var bundleName: String {
bundleURL.deletingPathExtension().lastPathComponent
}
static var currentInstallLocation: InstallLocation {
let currentDirectory = bundleURL.deletingLastPathComponent()
let standardizedCurrentDirectory = currentDirectory.standardizedFileURL.resolvingSymlinksInPath()
if standardizedCurrentDirectory == systemApplicationsDirectoryURL.standardizedFileURL.resolvingSymlinksInPath() {
return .applications
}
if standardizedCurrentDirectory == userApplicationsDirectoryURL.standardizedFileURL.resolvingSymlinksInPath() {
return .userApplications
}
if FileManager.default.fileExists(atPath: bundleURL.path) {
return .outsideApplications(bundleURL)
}
return .unknown
}
static var shouldOfferMoveToApplications: Bool {
!currentInstallLocation.isCanonicalInstall
}
static var systemApplicationsDirectoryURL: URL {
URL(fileURLWithPath: "/Applications", isDirectory: true)
}
static var userApplicationsDirectoryURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications", isDirectory: true)
}
static var preferredInstallDirectoryURL: URL? {
systemApplicationsDirectoryURL
}
static var preferredInstallBundleURL: URL? {
preferredInstallDirectoryURL?.appendingPathComponent(bundleURL.lastPathComponent)
}
static var knownInstallBundleURLs: [URL] {
let candidates = [
bundleURL,
systemApplicationsDirectoryURL.appendingPathComponent(bundleURL.lastPathComponent),
userApplicationsDirectoryURL.appendingPathComponent(bundleURL.lastPathComponent)
]
var seen = Set<String>()
return candidates.filter { candidate in
let key = candidate.standardizedFileURL.resolvingSymlinksInPath().path
guard seen.insert(key).inserted else { return false }
return FileManager.default.fileExists(atPath: candidate.path)
}
}
static var otherInstalledBundleURLs: [URL] {
knownInstallBundleURLs.filter { $0 != bundleURL }
}
static func moveCurrentAppToApplications(replacingExisting: Bool = true) throws -> URL {
let sourceURL = bundleURL
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
throw MoveError.sourceBundleMissing(sourceURL)
}
guard let destinationDirectoryURL = preferredInstallDirectoryURL else {
throw MoveError.destinationUnavailable
}
let destinationURL = destinationDirectoryURL.appendingPathComponent(sourceURL.lastPathComponent)
if sourceURL.standardizedFileURL.resolvingSymlinksInPath() == destinationURL.standardizedFileURL.resolvingSymlinksInPath() {
return destinationURL
}
try ensureDirectoryExists(at: destinationDirectoryURL)
if FileManager.default.fileExists(atPath: destinationURL.path) {
guard replacingExisting else {
throw MoveError.destinationExists(destinationURL)
}
try FileManager.default.removeItem(at: destinationURL)
}
guard FileManager.default.isWritableFile(atPath: destinationDirectoryURL.path) else {
throw MoveError.destinationNotWritable(destinationDirectoryURL)
}
do {
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
} catch {
throw MoveError.copyFailed(source: sourceURL, destination: destinationURL, underlying: error)
}
return destinationURL
}
static func moveToApplicationsAndRelaunch(replacingExisting: Bool = true) throws {
let destinationURL = try moveCurrentAppToApplications(replacingExisting: replacingExisting)
let openProcess = Process()
openProcess.executableURL = URL(fileURLWithPath: "/usr/bin/open")
openProcess.arguments = [destinationURL.path]
try openProcess.run()
DispatchQueue.main.async {
NSApplication.shared.terminate(nil)
}
}
private static func ensureDirectoryExists(at url: URL) throws {
if FileManager.default.fileExists(atPath: url.path) {
return
}
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
}

View File

@ -0,0 +1,131 @@
import Cocoa
import Observation
enum HotkeyMode: String, Codable, CaseIterable, Identifiable {
case hold // Tasten halten = aufnehmen, loslassen = stoppen
case toggle // Einmal drücken = starten, nochmal/Escape = stoppen
var id: String { rawValue }
var displayName: String {
switch self {
case .hold: return "Halten"
case .toggle: return "Drücken"
}
}
var description: String {
switch self {
case .hold: return "Tasten halten zum Aufnehmen, loslassen zum Stoppen"
case .toggle: return "Einmal drücken zum Starten, nochmal oder Escape zum Stoppen"
}
}
}
enum HotkeyEvent {
case down(WorkflowType) // Keys pressed
case up(WorkflowType) // Keys released (for hold mode)
case cancel // Escape pressed
}
@Observable
@MainActor
final class HotkeyService {
private var globalMonitor: Any?
private var localMonitor: Any?
private var keyMonitor: Any?
private var activeCombo: WorkflowType? // Which combo is currently held
var onHotkeyEvent: ((HotkeyEvent) -> Void)?
func start() {
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in
self?.handleFlags(event)
}
}
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in
self?.handleFlags(event)
}
return event
}
// Escape key monitor for toggle mode
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
Task { @MainActor in
if event.keyCode == 53 { // Escape
self?.handleEscape()
}
}
}
}
func stop() {
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
globalMonitor = nil
localMonitor = nil
keyMonitor = nil
}
private func handleFlags(_ event: NSEvent) {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
// fn + Shift + Control -> local transcription
if flags == [.function, .shift, .control] {
if activeCombo == nil {
activeCombo = .localTranscription
onHotkeyEvent?(.down(.localTranscription))
}
return
}
// fn + Shift -> transcription
if flags == [.function, .shift] {
if activeCombo == nil {
activeCombo = .transcription
onHotkeyEvent?(.down(.transcription))
}
return
}
// fn + Control -> Textverbesserer
if flags == [.function, .control] {
if activeCombo == nil {
activeCombo = .textImprover
onHotkeyEvent?(.down(.textImprover))
}
return
}
// fn + Option -> Rage Mode
if flags == [.function, .option] {
if activeCombo == nil {
activeCombo = .dampfAblassen
onHotkeyEvent?(.down(.dampfAblassen))
}
return
}
// fn + Command -> Emoji Mode
if flags == [.function, .command] {
if activeCombo == nil {
activeCombo = .emojiText
onHotkeyEvent?(.down(.emojiText))
}
return
}
// Keys released -- fire up event
if let combo = activeCombo {
activeCombo = nil
onHotkeyEvent?(.up(combo))
}
}
private func handleEscape() {
activeCombo = nil
onHotkeyEvent?(.cancel)
}
}

View File

@ -0,0 +1,91 @@
import Foundation
import Security
enum KeychainKey: String, CaseIterable, Codable {
case openAIAPIKey = "openAIAPIKey"
var label: String {
switch self {
case .openAIAPIKey: return "OpenAI API Key"
}
}
}
/// Stores preview credentials in the user's macOS Keychain.
enum KeychainService {
private static let service = "app.blitztext.preview.credentials"
static func save(key: KeychainKey, value: String) throws {
let data = Data(value.utf8)
var query = baseQuery(for: key)
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateStatus = SecItemUpdate(
baseQuery(for: key) as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
guard updateStatus == errSecSuccess else {
throw KeychainError.saveFailed(updateStatus)
}
return
}
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func load(key: KeychainKey) -> String? {
var query = baseQuery(for: key)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnData as String] = true
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8),
!value.isEmpty else {
return nil
}
return value
}
static func delete(key: KeychainKey) {
SecItemDelete(baseQuery(for: key) as CFDictionary)
}
/// Force the next `load` to re-read credentials.
static func invalidateCache() {
// Kept for call-site compatibility. Keychain reads do not use an in-memory cache.
}
static var isConfigured: Bool {
load(key: .openAIAPIKey) != nil
}
private static func baseQuery(for key: KeychainKey) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key.rawValue
]
}
}
enum KeychainError: LocalizedError {
case saveFailed(OSStatus)
var errorDescription: String? {
switch self {
case .saveFailed(let status):
return "Zugangsdaten konnten nicht im macOS Keychain gespeichert werden. Status: \(status)"
}
}
}

View File

@ -0,0 +1,209 @@
import Foundation
enum LLMError: LocalizedError {
case notConfigured
case networkError(String)
case apiError(String)
case noContent
var errorDescription: String? {
switch self {
case .notConfigured:
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
case .networkError(let msg):
return "Verbindungsproblem: \(msg)"
case .apiError(let msg):
return "Fehler von OpenAI: \(msg)"
case .noContent:
return "Keine Antwort erhalten. Bitte nochmal versuchen."
}
}
}
enum RewriteModel: String {
case fastEdit = "gpt-4o-mini"
case rageMode = "gpt-4o"
}
private struct OpenAIChatRequest: Encodable {
struct Message: Encodable {
let role: String
let content: String
}
let model: String
let messages: [Message]
let temperature: Double
}
private struct OpenAIChatResponse: Decodable {
struct Choice: Decodable {
struct Message: Decodable {
let content: String?
}
let message: Message?
}
let choices: [Choice]?
}
private struct OpenAIErrorResponse: Decodable {
struct APIError: Decodable {
let message: String?
}
let error: APIError?
}
enum LLMService {
private static let chatCompletionsURL = URL(string: "https://api.openai.com/v1/chat/completions")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 45
configuration.timeoutIntervalForResource = 45
return URLSession(configuration: configuration)
}()
static func improve(
text: String,
settings: TextImprovementSettings,
model: RewriteModel = .fastEdit
) async throws -> String {
try await complete(
text: text,
systemPrompt: buildSystemPrompt(settings: settings),
model: model,
temperature: 0.3
)
}
static func dampfAblassen(
text: String,
systemPrompt: String,
model: RewriteModel = .rageMode
) async throws -> String {
try await complete(
text: text,
systemPrompt: systemPrompt,
model: model,
temperature: 0.4
)
}
static func addEmojis(
text: String,
settings: EmojiTextSettings,
model: RewriteModel = .fastEdit
) async throws -> String {
try await complete(
text: text,
systemPrompt: buildEmojiSystemPrompt(density: settings.emojiDensity),
model: model,
temperature: 0.3
)
}
private static func complete(
text: String,
systemPrompt: String,
model: RewriteModel,
temperature: Double
) async throws -> String {
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
throw LLMError.notConfigured
}
let payload = OpenAIChatRequest(
model: model.rawValue,
messages: [
.init(role: "system", content: systemPrompt),
.init(role: "user", content: text),
],
temperature: temperature
)
var request = URLRequest(url: chatCompletionsURL)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 45
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw LLMError.networkError("Keine gültige Antwort")
}
guard httpResponse.statusCode == 200 else {
throw LLMError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
}
let result = try JSONDecoder().decode(OpenAIChatResponse.self, from: data)
guard let content = result.choices?.first?.message?.content,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LLMError.noContent
}
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func openAIErrorMessage(from data: Data) -> String? {
(try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data))?.error?.message
}
private static func buildEmojiSystemPrompt(density: EmojiTextSettings.EmojiDensity) -> String {
let densityInstruction: String
switch density {
case .wenig:
densityInstruction = "Setze nur vereinzelt Emojis ein, maximal 1-2 pro Absatz."
case .mittel:
densityInstruction = "Setze regelmaessig passende Emojis ein, etwa alle 1-2 Saetze."
case .viel:
densityInstruction = "Setze grosszuegig Emojis ein, gerne mehrere pro Satz."
}
return "Du erhaeltst ein gesprochenes Transkript. Gib den Text moeglichst originalgetreu zurueck, aber fuege passende Emojis ein. \(densityInstruction) Korrigiere offensichtliche Sprach- und Grammatikfehler. Behalte den Stil und die Bedeutung bei. Gib NUR den Text mit Emojis zurueck, keine Erklaerungen."
}
private static func buildSystemPrompt(settings: TextImprovementSettings) -> String {
if !settings.systemPrompt.isEmpty {
var prompt = settings.systemPrompt
if !settings.customTerms.isEmpty {
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
}
return prompt
}
var prompt = """
Du bist ein Lektor und Schreibassistent. Verbessere den folgenden Text:
- Korrigiere Rechtschreibung und Grammatik
- Verbessere die Formulierung und den Lesefluss
- Behalte die urspruengliche Bedeutung bei
- Gib NUR den verbesserten Text zurueck, keine Erklaerungen
"""
switch settings.tone {
case .formal:
prompt += "\n- Verwende einen formellen, professionellen Ton"
case .neutral:
prompt += "\n- Verwende einen neutralen, klaren Ton"
case .casual:
prompt += "\n- Verwende einen lockeren, natuerlichen Ton"
}
if !settings.customTerms.isEmpty {
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
}
if !settings.context.isEmpty {
prompt += "\n\nKontext: \(settings.context)"
}
return prompt
}
}

View File

@ -0,0 +1,55 @@
import Foundation
import Observation
import ServiceManagement
@Observable
@MainActor
final class LaunchAtLoginService {
var isEnabled = false
var helperText = "Blitztext startet nicht automatisch."
var errorText: String?
init() {
refresh()
}
func refresh() {
let status = SMAppService.mainApp.status
switch status {
case .enabled:
isEnabled = true
helperText = "Blitztext startet beim Anmelden automatisch."
case .notFound:
isEnabled = false
helperText = "Blitztext muss in /Applications liegen, damit der Anmeldestart verf\u{00FC}gbar ist."
case .requiresApproval:
isEnabled = true
helperText = "Noch in den Systemeinstellungen freigeben."
case .notRegistered:
isEnabled = false
helperText = "Blitztext startet nicht automatisch."
@unknown default:
isEnabled = false
helperText = "Auf diesem Mac nicht verfügbar."
}
}
func setEnabled(_ enabled: Bool) {
errorText = nil
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
refresh()
} catch {
refresh()
errorText = enabled
? "Anmeldestart konnte nicht aktiviert werden. Lege Blitztext in /Applications und versuche es erneut."
: "Anmeldestart konnte nicht deaktiviert werden. Bitte versuche es erneut."
}
}
}

View File

@ -0,0 +1,310 @@
import Foundation
import WhisperKit
struct LocalTranscriptionModel: Identifiable, Hashable {
let id: String
let url: URL
let isInstalled: Bool
init(id: String, url: URL, isInstalled: Bool = true) {
self.id = id
self.url = url
self.isInstalled = isInstalled
}
var displayName: String {
Self.displayName(for: id)
}
var installStateLabel: String {
isInstalled ? "Installiert" : "Nicht installiert"
}
var shortDisplayName: String {
if id.contains("small") {
return "Whisper Small"
}
if id.contains("base") {
return "Whisper Base"
}
if id.contains("tiny") {
return "Whisper Tiny"
}
if id.contains("turbo") {
return "Whisper Turbo"
}
if id.contains("large-v3") {
return "Whisper Large"
}
return displayName
}
static func displayName(for modelName: String) -> String {
if modelName.contains("small") {
return "Whisper Small"
}
if modelName.contains("base") {
return "Whisper Base"
}
if modelName.contains("tiny") {
return "Whisper Tiny"
}
if modelName.contains("turbo") {
return "Whisper Large v3 Turbo"
}
if modelName.contains("large-v3") {
return "Whisper Large v3"
}
return modelName
.replacingOccurrences(of: "openai_", with: "")
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
}
}
enum LocalTranscriptionError: LocalizedError {
case modelMissing(URL)
case downloadedModelInvalid(String)
case noText
var errorDescription: String? {
switch self {
case .modelMissing(let url):
return "Lokales Modell fehlt: \(url.path)"
case .downloadedModelInvalid(let modelName):
return "Das geladene Modell ist unvollständig: \(modelName)"
case .noText:
return "Das lokale Modell hat keinen Text erkannt."
}
}
}
actor LocalTranscriptionService {
static let shared = LocalTranscriptionService()
static let defaultModelName = "openai_whisper-large-v3-v20240930_626MB"
static let fastModelName = "openai_whisper-large-v3-v20240930_turbo_632MB"
static let recommendedFastModelName = "openai_whisper-small_216MB"
static let modelRepo = "argmaxinc/whisperkit-coreml"
static let supportedModelNames = [
recommendedFastModelName,
fastModelName,
defaultModelName
]
static let modelPageURL = URL(
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_626MB"
)!
static let fastModelPageURL = URL(
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_turbo_632MB"
)!
static let recommendedFastModelPageURL = URL(
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-small_216MB"
)!
static func modelPageURL(for modelName: String) -> URL {
switch normalizedModelName(modelName) {
case recommendedFastModelName:
return recommendedFastModelPageURL
case fastModelName:
return fastModelPageURL
case defaultModelName:
return modelPageURL
default:
return URL(string: "https://huggingface.co/\(modelRepo)/tree/main/\(normalizedModelName(modelName))")!
}
}
private var whisperKit: WhisperKit?
private var loadedModelName: String?
static var isModelInstalled: Bool {
isModelInstalled(defaultModelName)
}
static func modelURL(named modelName: String) -> URL {
AppSupportPaths.whisperKitModelsDirectoryURL.appendingPathComponent(normalizedModelName(modelName), isDirectory: true)
}
static func isModelInstalled(_ modelName: String) -> Bool {
isUsableModel(at: modelURL(named: modelName))
}
static func normalizedModelName(_ modelName: String) -> String {
let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? recommendedFastModelName : trimmed
}
static func installedModels() -> [LocalTranscriptionModel] {
let directory = AppSupportPaths.whisperKitModelsDirectoryURL
let urls = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)) ?? []
return urls
.filter { isUsableModel(at: $0) }
.map { LocalTranscriptionModel(id: $0.lastPathComponent, url: $0) }
.sorted { lhs, rhs in
if lhs.id == recommendedFastModelName { return true }
if rhs.id == recommendedFastModelName { return false }
if lhs.id == fastModelName { return true }
if rhs.id == fastModelName { return false }
if lhs.id == defaultModelName { return true }
if rhs.id == defaultModelName { return false }
return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
}
static func modelOptions() -> [LocalTranscriptionModel] {
var seen = Set<String>()
let installed = installedModels()
let installedByID = Dictionary(uniqueKeysWithValues: installed.map { ($0.id, $0) })
let orderedIDs = supportedModelNames + installed.map(\.id)
return orderedIDs.compactMap { modelName in
let normalizedName = normalizedModelName(modelName)
guard seen.insert(normalizedName).inserted else { return nil }
if let installedModel = installedByID[normalizedName] {
return installedModel
}
return LocalTranscriptionModel(
id: normalizedName,
url: modelURL(named: normalizedName),
isInstalled: false
)
}
}
static func resolvedModelName(_ preferredModelName: String) -> String {
let normalizedName = normalizedModelName(preferredModelName)
if isModelInstalled(normalizedName) {
return normalizedName
}
return installedModels().first?.id ?? normalizedName
}
static func shouldAutoSelectRecommendedFastModel(currentModelName: String) -> Bool {
guard isModelInstalled(recommendedFastModelName) else {
return false
}
return currentModelName == defaultModelName || currentModelName == fastModelName
}
func prepare(modelName: String) async throws {
_ = try await pipeline(modelName: modelName)
}
func downloadAndInstall(
modelName: String,
progressHandler: @escaping @Sendable (Double) -> Void
) async throws -> URL {
let normalizedName = Self.normalizedModelName(modelName)
let destinationURL = Self.modelURL(named: normalizedName)
if Self.isUsableModel(at: destinationURL) {
progressHandler(1)
return destinationURL
}
let fileManager = FileManager.default
try fileManager.createDirectory(
at: AppSupportPaths.whisperKitModelsDirectoryURL,
withIntermediateDirectories: true
)
let downloadRoot = AppSupportPaths.localModelsDirectoryURL
.appendingPathComponent("downloads", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: downloadRoot, withIntermediateDirectories: true)
do {
let downloadedURL = try await WhisperKit.download(
variant: normalizedName,
downloadBase: downloadRoot,
from: Self.modelRepo
) { progress in
let fraction = progress.fractionCompleted
progressHandler(fraction.isFinite ? fraction : 0)
}
guard Self.isUsableModel(at: downloadedURL) else {
throw LocalTranscriptionError.downloadedModelInvalid(normalizedName)
}
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.moveItem(at: downloadedURL, to: destinationURL)
try? fileManager.removeItem(at: downloadRoot)
if loadedModelName == normalizedName {
whisperKit = nil
loadedModelName = nil
}
progressHandler(1)
return destinationURL
} catch {
try? fileManager.removeItem(at: downloadRoot)
throw error
}
}
func transcribe(audioURL: URL, language: String, modelName: String) async throws -> String {
let resolvedLanguage = language.trimmingCharacters(in: .whitespacesAndNewlines)
let decodeOptions = DecodingOptions(
task: .transcribe,
language: resolvedLanguage.isEmpty ? nil : resolvedLanguage
)
let pipeline = try await pipeline(modelName: modelName)
let results = try await pipeline.transcribe(
audioPath: audioURL.path,
decodeOptions: decodeOptions
)
let text = results
.map(\.text)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw LocalTranscriptionError.noText
}
return text
}
private func pipeline(modelName: String) async throws -> WhisperKit {
let resolvedModelName = Self.resolvedModelName(modelName)
if let whisperKit, loadedModelName == resolvedModelName {
return whisperKit
}
let url = Self.modelURL(named: resolvedModelName)
guard Self.isUsableModel(at: url) else {
throw LocalTranscriptionError.modelMissing(url)
}
let loaded = try await WhisperKit(
modelFolder: url.path,
verbose: false,
prewarm: true,
load: true,
download: false
)
whisperKit = loaded
loadedModelName = resolvedModelName
return loaded
}
private static func isUsableModel(at url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.appendingPathComponent("AudioEncoder.mlmodelc").path) &&
FileManager.default.fileExists(atPath: url.appendingPathComponent("MelSpectrogram.mlmodelc").path) &&
FileManager.default.fileExists(atPath: url.appendingPathComponent("TextDecoder.mlmodelc").path)
}
}

View File

@ -0,0 +1,35 @@
import Foundation
enum TranscriptionQualityService {
static let minimumRecordingDuration: TimeInterval = 0.3
static func shouldRejectRecording(duration: TimeInterval) -> Bool {
duration < minimumRecordingDuration
}
static func cleanedTranscript(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func isLikelyArtifact(_ text: String, recordingDuration: TimeInterval) -> Bool {
let cleaned = cleanedTranscript(text)
guard !cleaned.isEmpty else { return true }
let words = cleaned.split { $0.isWhitespace || $0.isNewline }
let letters = cleaned.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
if letters == 0 {
return true
}
if recordingDuration < 0.55 && (words.count >= 5 || cleaned.count >= 32) {
return true
}
if recordingDuration < 0.8 && cleaned.count >= 56 {
return true
}
return false
}
}

View File

@ -0,0 +1,135 @@
import Foundation
enum TranscriptionError: LocalizedError {
case noFile
case notConfigured
case networkError(String)
case apiError(String)
var errorDescription: String? {
switch self {
case .noFile:
return "Keine Audio-Datei gefunden"
case .notConfigured:
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
case .networkError(let msg):
return "Netzwerkfehler: \(msg)"
case .apiError(let msg):
return "OpenAI-Fehler: \(msg)"
}
}
}
private struct TranscriptionOpenAIErrorResponse: Decodable {
struct APIError: Decodable {
let message: String?
}
let error: APIError?
}
enum TranscriptionService {
private static let remoteModel = "whisper-1"
private static let transcriptionsURL = URL(string: "https://api.openai.com/v1/audio/transcriptions")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 60
configuration.timeoutIntervalForResource = 60
return URLSession(configuration: configuration)
}()
static func transcribe(
audioURL: URL,
customTerms: [String] = [],
language: String? = nil
) async throws -> String {
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
throw TranscriptionError.notConfigured
}
return try await Task.detached(priority: .userInitiated) {
defer {
try? FileManager.default.removeItem(at: audioURL)
}
let boundary = UUID().uuidString
var request = URLRequest(url: transcriptionsURL)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("text/plain, application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 60
request.cachePolicy = .reloadIgnoringLocalCacheData
let audioData = try Data(contentsOf: audioURL, options: [.mappedIfSafe])
var body = Data()
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"audio.m4a\"\r\n")
body.append("Content-Type: audio/m4a\r\n\r\n")
body.append(audioData)
body.append("\r\n")
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"model\"\r\n\r\n")
body.append(remoteModel)
body.append("\r\n")
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"response_format\"\r\n\r\n")
body.append("text")
body.append("\r\n")
if !customTerms.isEmpty {
let prompt = "Eigennamen und Begriffe: \(customTerms.joined(separator: ", "))"
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"prompt\"\r\n\r\n")
body.append(prompt)
body.append("\r\n")
}
if let language, !language.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"language\"\r\n\r\n")
body.append(language.trimmingCharacters(in: .whitespacesAndNewlines))
body.append("\r\n")
}
body.append("--\(boundary)--\r\n")
request.httpBody = body
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TranscriptionError.networkError("Ungueltige Antwort")
}
guard httpResponse.statusCode == 200 else {
throw TranscriptionError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
}
guard let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw TranscriptionError.apiError("Transkription fehlgeschlagen")
}
return text
}.value
}
private static func openAIErrorMessage(from data: Data) -> String? {
(try? JSONDecoder().decode(TranscriptionOpenAIErrorResponse.self, from: data))?.error?.message
}
}
private extension Data {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
append(data)
}
}
}

View File

@ -0,0 +1,96 @@
import SwiftUI
import Combine
/// Manages waveform bar levels and an internal display timer.
/// Lives as a reference type so the Timer closure always reads fresh state.
@MainActor
final class WaveformState: ObservableObject {
@Published var levels: [CGFloat] = Array(repeating: 0.03, count: 40)
/// The current audio level fed from the parent -- updated on every
/// SwiftUI body evaluation so the timer always has the latest value.
var currentAudioLevel: Float = 0
private var phase: Double = 0
private var timer: Timer?
func startTimer() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.tick()
}
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
func reset() {
levels = Array(repeating: 0.03, count: 40)
phase = 0
}
private func tick() {
phase += 0.15
let base = CGFloat(currentAudioLevel)
levels.removeFirst()
let jitter = CGFloat.random(in: -0.06...0.06)
let breathe = sin(phase) * 0.03
let newLevel = max(0.03, min(1.0, base + jitter + breathe))
levels.append(newLevel)
}
deinit {
timer?.invalidate()
}
}
struct WaveformView: View {
var audioLevel: Float
var isRecording: Bool
var accentColor: Color = .primary
@StateObject private var state = WaveformState()
var body: some View {
HStack(spacing: 2) {
ForEach(Array(state.levels.enumerated()), id: \.offset) { _, level in
Capsule()
.fill(barColor(for: level))
.frame(width: 2.5, height: max(2, level * 40))
}
}
.frame(height: 40)
.onChange(of: audioLevel) { _, newLevel in
state.currentAudioLevel = newLevel
}
.onChange(of: isRecording) { _, recording in
if recording {
state.currentAudioLevel = audioLevel
state.startTimer()
} else {
state.stopTimer()
withAnimation(.easeOut(duration: 0.4)) {
state.reset()
}
}
}
.onAppear {
state.currentAudioLevel = audioLevel
if isRecording {
state.startTimer()
}
}
.onDisappear {
state.stopTimer()
}
}
private func barColor(for level: CGFloat) -> Color {
let opacity = 0.25 + Double(level) * 0.75
return accentColor.opacity(opacity)
}
}

56
BlitztextMac/project.yml Normal file
View File

@ -0,0 +1,56 @@
name: BlitztextMac
options:
bundleIdPrefix: app.blitztext
deploymentTarget:
macOS: "14.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.10"
MACOSX_DEPLOYMENT_TARGET: "14.0"
ONLY_ACTIVE_ARCH: NO
MARKETING_VERSION: "1.5"
CURRENT_PROJECT_VERSION: "15"
packages:
ArgmaxOSS:
url: https://github.com/argmaxinc/argmax-oss-swift.git
exactVersion: 0.18.0
targets:
BlitztextMac:
type: application
platform: macOS
sources:
- path: App
- path: Features
- path: Services
- path: Views
- path: Resources
buildPhase: resources
excludes:
- AppIcon.icns
- Info.plist
- BlitztextMac.entitlements
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: app.blitztext.mac
PRODUCT_NAME: Blitztext
INFOPLIST_FILE: Resources/Info.plist
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CODE_SIGN_ENTITLEMENTS: Resources/BlitztextMac.entitlements
ENABLE_HARDENED_RUNTIME: true
COMBINE_HIDPI_IMAGES: true
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- package: ArgmaxOSS
product: WhisperKit
entitlements:
path: Resources/BlitztextMac.entitlements
properties:
com.apple.security.app-sandbox: false
com.apple.security.device.audio-input: true
com.apple.security.network.client: true

9
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,9 @@
# Code Of Conduct
Be kind, direct, and constructive.
This is a small experimental project. Good discussion is welcome; personal attacks, harassment, or hostile behavior are not.
If a conversation gets heated, slow down and move back to the concrete technical question.
Maintainers may close, hide, or moderate issues, comments, and pull requests that make collaboration harder or unsafe.

50
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,50 @@
# Contributing
Thanks for taking a look at Blitztext macOS Preview.
This repository is intentionally a preview. Contributions should make it easier to learn from, build, fork, or safely extend.
## Good First Contributions
- improve build instructions
- fix confusing UI text
- improve error messages
- add tests around parsing or quality filters
- document local model experiments
- simplify setup
## Before Opening A Pull Request
Please include:
- what changed
- why it changed
- how you tested it
- whether you used AI-assisted coding tools
Keep changes small when possible. Avoid unrelated cleanup in the same PR.
## Local Build
```bash
./build.sh --debug
```
## Security And Privacy
- Never commit API keys, tokens, private audio, or confidential transcripts.
- Avoid adding telemetry, hosted services, or external dependencies without a clear issue first.
- Call out privacy-impacting changes in the pull request description.
- Keep the preview honest: do not describe remote OpenAI workflows as offline or local.
## Project Boundaries
This preview currently does not include:
- other platforms
- a hosted backend
- packaged releases
- bundled local model files
- local text rewriting
Those can be discussed in issues, but please keep PRs focused on the current macOS preview unless a maintainer agrees on a larger direction first.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Blitztext contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

146
README.md Normal file
View File

@ -0,0 +1,146 @@
# Blitztext App
Blitztext App is an experimental open-source macOS menubar app for turning speech into text.
It is intentionally small and unfinished. The goal is to make a real workflow visible and hackable: press a hotkey, speak, get text back, optionally rewrite it, and paste it into the app you were using.
This is a learning and experimentation project, not a polished product.
> Preview status: bring your own OpenAI API key, no hosted backend, no warranty, no support guarantee.
## What It Does
- **Blitztext**: record speech and transcribe it.
- **Blitztext+**: record speech, transcribe it, then turn the rough draft into cleaner writing.
- **Blitztext $%&!**: turn frustrated speech into a calmer message.
- **Blitztext :)**: add fitting emojis to dictated text.
## Important Preview Notes
- macOS only.
- Bring your own OpenAI API key.
- No hosted Blitztext backend is included or provided.
- In online mode, audio and text are sent directly from the app to the OpenAI API.
- Optional local transcription via WhisperKit/CoreML if you install a compatible model locally.
- `./build.sh` creates a locally ad-hoc-signed development app. No notarized release binary is provided.
- Not production ready.
- No warranty and no support guarantee.
You are welcome to use, fork, adapt, and share this project under the license terms.
The intent is not to ship a one-click finished app. The intent is to make a real AI workflow understandable: clone it, build it, read the code, change it, break it, fix it, and suggest improvements. If you only want to download something and never look inside, this preview will probably feel rough. If you want to learn how a small native macOS AI app is put together, you are in the right place.
## Screenshots
<table>
<tr>
<td><img src="docs/screenshots/online-mode.png" alt="Blitztext online transcription mode" width="420"></td>
<td><img src="docs/screenshots/local-mode.png" alt="Blitztext secure local transcription mode" width="420"></td>
</tr>
<tr>
<td><img src="docs/screenshots/local-model-picker.png" alt="Blitztext local model picker" width="420"></td>
<td><img src="docs/screenshots/settings-customize.png" alt="Blitztext settings and customization view" width="420"></td>
</tr>
</table>
## Requirements
- macOS 14 or newer
- Xcode 16 or newer (Swift 5.10), with Command Line Tools installed and selected for `xcodebuild`
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) to generate the Xcode project
- For online transcription and rewriting: an OpenAI API key with access to:
- `whisper-1` for transcription
- `gpt-4o-mini` and optionally `gpt-4o` for rewriting
- For local-only transcription: a WhisperKit CoreML model in:
`~/Library/Application Support/Blitztext/models/whisperkit/`
The build also pulls one Swift Package dependency automatically:
- [`argmax-oss-swift`](https://github.com/argmaxinc/argmax-oss-swift) (WhisperKit) — used for local on-device transcription.
Install XcodeGen if needed:
```bash
brew install xcodegen
```
## Build And Run
```bash
git clone https://github.com/cmagnussen/blitztext-app.git
cd blitztext-app
./build.sh --run
```
For a local install into `/Applications`:
```bash
./build.sh --install --run
```
The generated `.app` is ad-hoc signed for local development only. Do not treat it as a trusted redistributable binary. A public binary release would need Developer ID signing and notarization.
On first launch, either paste your own OpenAI API key for online workflows or install a WhisperKit CoreML model for local transcription. Rewriting workflows still require OpenAI.
For fully local transcription, install a WhisperKit CoreML model and enable **Sicherer Lokaler Modus** in the app.
For a slower, more explicit walkthrough, see [docs/setup.md](docs/setup.md).
## Permissions
Blitztext asks for:
- **Microphone**: to record your voice.
- **Accessibility**: to paste the result back into the app you were using.
If you do not grant Accessibility permission, you can still copy results manually.
## Data Flow
The preview has no custom backend.
```text
Online transcription: Your Mac -> OpenAI Audio Transcriptions API
Text rewriting: Your Mac -> OpenAI Chat Completions API
Local transcription: Your Mac -> WhisperKit/CoreML on device
```
The app stores your OpenAI API key in the user's macOS Keychain.
Read [docs/privacy.md](docs/privacy.md) before using the preview with sensitive content.
## Project Structure
```text
BlitztextMac/
App/ App lifecycle and paste handling
Features/ Workflows, menu bar UI, settings
Services/ Recording, OpenAI calls, hotkeys, local storage
Views/ Shared SwiftUI views
build.sh Local build script
docs/ Setup, privacy, roadmap, preflight, landing page notes
```
## Local Models
Local transcription is available as an experimental WhisperKit/CoreML path. The app does not bundle a model; choose one in the app, click install, and then switch on **Sicherer Lokaler Modus** from the menu bar or settings.
See [docs/local-models.md](docs/local-models.md).
## Contributing
Contributions are welcome, especially if they make the preview easier to build, understand, or fork.
Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
## Support And Roadmap
This preview has no formal support promise. See [SUPPORT.md](SUPPORT.md) for how to ask for help without sharing secrets.
The current direction is documented in [ROADMAP.md](ROADMAP.md). Maintainer-facing release checks live in [docs/open-source-preflight.md](docs/open-source-preflight.md).
## License
Code is released under the MIT License. See [LICENSE](LICENSE).
Project names, logos, and app icons are not automatically granted as trademarks or brand assets. See [TRADEMARKS.md](TRADEMARKS.md).

31
ROADMAP.md Normal file
View File

@ -0,0 +1,31 @@
# Roadmap
This is a preview roadmap, not a promise.
## Current Scope
- macOS menubar app
- local recording and hotkeys
- direct OpenAI API calls with a user-provided API key
- transcription, rewriting, calmer-message, and emoji workflows
- no hosted backend
- no other platforms
- no packaged public release
## Next Useful Work
- Make first-run setup clearer.
- Improve credential setup, validation, and recovery UX.
- Add a small automated test layer around prompt construction and text quality filters.
- Add provider boundaries so OpenAI and future local transcription can be swapped more cleanly.
- Prototype local transcription with WhisperKit or whisper.cpp.
- Reduce the Accessibility blast radius, ideally by moving synthetic paste into a smaller helper with narrower responsibilities.
- Add stronger supply-chain checks around downloaded local speech models.
- Add signed and notarized release builds when the project is ready for non-developer users.
## Not In Scope Yet
- Production support.
- Accounts, sync, teams, or hosted infrastructure.
- Claims that the app is offline or privacy-complete.
- App Store distribution.

36
SECURITY.md Normal file
View File

@ -0,0 +1,36 @@
# Security Policy
Blitztext macOS Preview is experimental software.
It is provided as-is, without warranty, support guarantees, or production-readiness claims.
## Supported Versions
Only the current `main` branch is considered for security fixes.
## Reporting A Vulnerability
Please do not open a public issue with sensitive security details.
Use GitHub private vulnerability reporting for this repository. Maintainers should enable it before making the repository public.
If private vulnerability reporting is not available yet, open a minimal public issue titled `Security contact request` without technical details.
Do not include OpenAI API keys, access tokens, private recordings, or confidential transcripts in a report.
Include:
- what you found
- how to reproduce it
- what data or system access could be affected
- your suggested fix, if you have one
## Security Notes
- The app sends audio and text directly to OpenAI when you use the remote workflows.
- Your OpenAI API key is stored in the user's macOS Keychain.
- Temporary audio files may exist briefly during processing.
- Accessibility permission allows the app to paste text into the current app.
- The app currently runs **without** the macOS App Sandbox. This is a deliberate trade-off for the preview: the menubar workflow needs Accessibility-based paste into arbitrary frontmost apps, system-wide hotkeys, and Application Support paths for local WhisperKit models, all of which are awkward or impossible inside a strict sandbox. Hardened Runtime is enabled, and the entitlements are limited to microphone input and outbound network access. Reintroducing the sandbox is on the roadmap once these flows are reworked.
Do not use this preview for confidential or regulated data without your own review.

25
SUPPORT.md Normal file
View File

@ -0,0 +1,25 @@
# Support
Blitztext App is an experimental preview. There is no service-level agreement, paid support channel, or guarantee that issues will be fixed.
## Before Asking For Help
- Make sure you can build the app with `./build.sh --debug`.
- Check that your OpenAI API key is entered in the app settings.
- Confirm that macOS microphone permission is granted.
- Grant Accessibility permission if you expect automatic paste into other apps.
- Read [docs/privacy.md](docs/privacy.md) before testing with sensitive content.
## Where To Ask
Use GitHub Issues for reproducible bugs and focused feature ideas.
Please do not post:
- OpenAI API keys
- access tokens
- private audio recordings
- confidential transcripts
- screenshots that show sensitive content
For security-sensitive reports, follow [SECURITY.md](SECURITY.md) instead of opening a public issue.

7
TRADEMARKS.md Normal file
View File

@ -0,0 +1,7 @@
# Trademarks And Branding
The source code in this repository is licensed under the MIT License.
The project name, app name, logos, icons, and visual identity are not granted as trademarks or brand assets by the MIT License.
You may fork the code under the license terms. If you publish a fork as a separate app or service, use your own name, icon, and branding unless you have explicit permission.

186
build.sh Executable file
View File

@ -0,0 +1,186 @@
#!/bin/bash
set -euo pipefail
# Blitztext macOS App - Build & Run
# Voraussetzungen: Full Xcode with Command Line Tools, xcodegen
RUN_AFTER=false
INSTALL_APP=false
BUILD_CONFIGURATION="Release"
UNIVERSAL_ARCHS="arm64 x86_64"
for arg in "$@"; do
case "$arg" in
--debug)
BUILD_CONFIGURATION="Debug"
;;
--run)
RUN_AFTER=true
;;
--install)
INSTALL_APP=true
;;
--release)
BUILD_CONFIGURATION="Release"
;;
*)
echo "Unbekannte Option: $arg"
echo "Verwendung: ./build.sh [--install] [--run] [--release] [--debug]"
exit 1
;;
esac
done
verify_universal_app() {
local app_path="$1"
local app_name
local binary_path
local archs
app_name="$(basename "$app_path" .app)"
binary_path="$app_path/Contents/MacOS/$app_name"
if [ ! -f "$binary_path" ]; then
echo "❌ Konnte App-Binary nicht finden: $binary_path"
exit 1
fi
archs="$(lipo -archs "$binary_path" 2>/dev/null || true)"
if [[ -z "$archs" ]]; then
echo "❌ Konnte Architekturen nicht lesen: $binary_path"
file "$binary_path" 2>/dev/null || true
exit 1
fi
if [[ " $archs " != *" arm64 "* || " $archs " != *" x86_64 "* ]]; then
echo "❌ Build ist nicht universal. Erwartet: arm64 + x86_64"
echo " Gefunden: $archs"
file "$binary_path" 2>/dev/null || true
exit 1
fi
echo "✅ Universal Binary verifiziert: $archs"
}
ensure_xcodebuild_available() {
if xcodebuild -version >/dev/null 2>&1; then
return
fi
local default_xcode="/Applications/Xcode.app/Contents/Developer"
if [ -d "$default_xcode" ]; then
export DEVELOPER_DIR="$default_xcode"
if xcodebuild -version >/dev/null 2>&1; then
echo "⚠️ Aktiver Developer-Pfad nutzt kein vollständiges Xcode. Verwende: $DEVELOPER_DIR"
return
fi
fi
echo "❌ xcodebuild ist nicht verfügbar."
echo " Installiere Xcode und wähle es mit:"
echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"
exit 1
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR/BlitztextMac"
PROJECT_FILE="$PROJECT_DIR/BlitztextMac.xcodeproj"
DERIVED_DATA_PATH="$SCRIPT_DIR/.derivedData-blitztextmac-build"
cd "$PROJECT_DIR"
ensure_xcodebuild_available
if command -v xcodegen &> /dev/null; then
echo "⚙️ Generiere Xcode-Projekt ..."
xcodegen generate 2>&1
elif [ -d "$PROJECT_FILE" ]; then
echo "⚠️ xcodegen nicht gefunden nutze vorhandenes Xcode-Projekt."
else
echo "❌ xcodegen fehlt."
echo " Installiere xcodegen explizit mit:"
echo " brew install xcodegen"
echo " Oder stelle sicher, dass $PROJECT_FILE vorhanden ist."
exit 1
fi
# Bauen
echo "🔨 Baue Blitztext ..."
xcodebuild \
-project BlitztextMac.xcodeproj \
-scheme BlitztextMac \
-destination 'platform=macOS' \
-configuration "$BUILD_CONFIGURATION" \
-derivedDataPath "$DERIVED_DATA_PATH" \
ONLY_ACTIVE_ARCH=NO \
ARCHS="$UNIVERSAL_ARCHS" \
clean build
# App finden
APP_PATH="$DERIVED_DATA_PATH/Build/Products/$BUILD_CONFIGURATION/Blitztext.app"
if [ ! -d "$APP_PATH" ]; then
echo "❌ Build fehlgeschlagen keine App gefunden."
exit 1
fi
verify_universal_app "$APP_PATH"
# Resources manuell ins Bundle kopieren (xcodegen kopiert sie nicht automatisch)
echo "📋 Kopiere Resources ..."
RESOURCES_DIR="$APP_PATH/Contents/Resources"
mkdir -p "$RESOURCES_DIR"
cp -f "$PROJECT_DIR/Resources/AppIcon.icns" "$RESOURCES_DIR/" 2>/dev/null || true
cp -f "$PROJECT_DIR/Resources/menubar_icon.png" "$RESOURCES_DIR/" 2>/dev/null || true
cp -f "$PROJECT_DIR/Resources/menubar_icon@2x.png" "$RESOURCES_DIR/" 2>/dev/null || true
# In Projektordner kopieren
DEST="$SCRIPT_DIR/Blitztext.app"
rm -rf "$DEST"
cp -R "$APP_PATH" "$DEST"
echo "🔏 Signiere lokale Development-App ad-hoc. Dieses Artefakt ist nicht notarisiert."
codesign --force --sign - "$DEST" 2>&1
verify_universal_app "$DEST"
RUN_TARGET="$DEST"
if [ "$INSTALL_APP" = true ]; then
APPS_DIR="/Applications"
INSTALL_DEST="$APPS_DIR/Blitztext.app"
if [ ! -w "$APPS_DIR" ]; then
echo "❌ /Applications ist nicht beschreibbar."
echo " Fuehre den Befehl mit passenden Rechten erneut aus oder ziehe die App manuell nach /Applications."
exit 1
fi
rm -rf "$INSTALL_DEST"
cp -R "$DEST" "$INSTALL_DEST"
echo "🔏 Signiere lokale Development-App ad-hoc. Dieses Artefakt ist nicht notarisiert."
codesign --force --sign - "$INSTALL_DEST" 2>&1
verify_universal_app "$INSTALL_DEST"
RUN_TARGET="$INSTALL_DEST"
fi
echo ""
echo "✅ Fertig! App liegt unter:"
echo " $DEST"
if [ "$INSTALL_APP" = true ]; then
echo " $RUN_TARGET"
fi
echo ""
echo "Build-Typ: $BUILD_CONFIGURATION"
echo "Architekturen: $UNIVERSAL_ARCHS"
echo "Kompatibel: Apple Silicon + Intel (macOS 14+)"
echo ""
echo "Naechste Schritte:"
echo "1. App starten"
echo "2. Mikrofon erlauben"
echo "3. Fuer direktes Einfuegen zusaetzlich Bedienungshilfen erlauben"
echo "4. In Blitztext deinen eigenen OpenAI API Key eintragen"
echo "5. Loslegen und bei Bedarf im Code weiterbauen"
echo ""
# Optional: direkt starten
if [ "$RUN_AFTER" = true ]; then
echo "🚀 Starte Blitztext ..."
open "$RUN_TARGET"
fi

33
docs/github-settings.md Normal file
View File

@ -0,0 +1,33 @@
# GitHub Settings Checklist
These settings are not stored in the repository. Configure them in GitHub before going public.
## Security
- Enable Dependabot alerts.
- Enable secret scanning.
- Enable push protection for supported secret types.
- Enable private vulnerability reporting when available.
## Branch Protection
Protect `main`:
- require pull request before merge
- require at least one approval
- require the CI workflow to pass
- dismiss stale approvals when new commits are pushed
- block force pushes
## Actions
- Keep default workflow permissions read-only.
- Require approval for workflows from first-time contributors.
- Do not add repository secrets unless they are truly needed.
## Community
- Keep Issues enabled for bugs and focused requests.
- Enable Discussions only if you want a lower-friction place for questions.
- Set repository topics after the project is public.
- Review the GitHub community profile before sharing the repo widely.

View File

@ -0,0 +1,82 @@
# Landing Page Brief
Domain: `blitztext.app`
Goal: a very small landing page for an experimental open-source macOS preview.
## Hero
Headline:
> Blitztext macOS Preview
Subline:
> Speak your thoughts. Turn them into text, cleaner writing, or calmer messages.
Body:
> An experimental open-source macOS menubar app. Not finished, not hosted, not plug-and-play. Built to learn from, fork, and improve.
Primary CTA:
> View on GitHub
Secondary CTA:
> Read setup guide
Small line:
> Bring your own OpenAI API key. Optional local transcription. No hosted Blitztext backend.
## Sections
1. What it does
- Dictate
- Improve
- Calm down
- Add emojis
2. How it works
- Build the app locally
- Paste your own OpenAI API key
- Press a hotkey and speak
- Get text back on the clipboard
3. Open-source preview
- macOS-only
- MIT License
- experimental
- no warranty
- optional local transcription with user-installed WhisperKit models
4. Privacy, plainly
- online workflows send audio and text to OpenAI
- secure local mode keeps transcription on device
- no public Blitztext backend
- rewriting still uses OpenAI
- do your own review before sensitive use
5. Roadmap
- easier setup
- signed releases
- local transcription experiments
- clearer setup and security docs
- community issues
## Do Not Promise
- offline use
- production readiness
- hosted service
- free usage without API costs
- no data leaves the device
- guaranteed support
- other platforms
- bundled local models
- local rewriting
## Visual Direction
Use a real macOS screenshot or short demo GIF. Keep the page calm, sparse, and honest. Avoid fake metrics, oversized SaaS claims, and corporate origin story.

72
docs/local-models.md Normal file
View File

@ -0,0 +1,72 @@
# Local Models
Blitztext can run transcription locally with WhisperKit/CoreML. The app does not bundle a speech model, but it can download the selected compatible model from Hugging Face into the local cache.
## Recommended First Model
Use Whisper Small for the first local test. It is multilingual, supports German, and is much lighter than the large variants.
- [argmaxinc/whisperkit-coreml: openai_whisper-small_216MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-small_216MB)
Local cache path:
```text
~/Library/Application Support/Blitztext/models/whisperkit/openai_whisper-small_216MB
```
## Other Compatible Models
You can also install larger WhisperKit CoreML models into the same cache directory:
- [openai_whisper-large-v3-v20240930_626MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_626MB)
- [openai_whisper-large-v3-v20240930_turbo_632MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_turbo_632MB)
The app detects installed model folders that contain `AudioEncoder.mlmodelc`, `MelSpectrogram.mlmodelc`, and `TextDecoder.mlmodelc`.
## Install From The App
Open Blitztext, go to **Settings > Anpassen**, choose a local model, and click **Installieren**. You can also switch on **Sicherer Lokaler Modus** from the main popover; if the selected model is missing, Blitztext starts the download and installs it into the local cache.
After the model is installed, the Blitztext transcription workflow can run in local mode. The rewriting workflows still use OpenAI, so they are paused while secure local mode is active.
## Optional Manual Install
If you prefer the CLI path, install the Hugging Face CLI so the `hf` command is available:
```bash
python3 -m pip install --upgrade "huggingface_hub[cli]"
```
Create the local model cache:
```bash
mkdir -p "$HOME/Library/Application Support/Blitztext/models/whisperkit"
```
Download the recommended first model:
```bash
hf download argmaxinc/whisperkit-coreml \
--include 'openai_whisper-small_216MB/*' \
--local-dir "$HOME/Library/Application Support/Blitztext/models/whisperkit" \
--max-workers 4
```
Expected folder layout:
```text
~/Library/Application Support/Blitztext/models/whisperkit/
openai_whisper-small_216MB/
AudioEncoder.mlmodelc/
MelSpectrogram.mlmodelc/
TextDecoder.mlmodelc/
```
If the folder is nested differently, the app will not detect the model.
## Notes
- First use can be slower because the model has to load and prewarm.
- Local transcription avoids sending audio to OpenAI for the Blitztext workflow.
- The app currently supports local transcription only, not local rewriting.
- Models are downloaded on demand so the repository and app package stay small and auditable.

View File

@ -0,0 +1,31 @@
# Open Source Preflight
Use this checklist before making the repository public.
## P0 Before Public
- Run a local build with `./build.sh --debug`.
- Run a secret scan across the working tree and commit history.
- Confirm there are no private URLs, hosted backend credentials, internal docs, or old project references.
- Keep the repository private until another maintainer has reviewed the first public commit.
- Confirm the root `LICENSE`, `README.md`, `SECURITY.md`, `CONTRIBUTING.md`, and `SUPPORT.md` are present.
- Make the preview status explicit: experimental, bring your own OpenAI API key, no hosted backend, no warranty.
- Enable GitHub private vulnerability reporting, secret scanning, and push protection before switching the repo public.
- Enable Dependabot alerts.
- Protect `main` with pull requests, at least one review, and required CI checks.
- Keep GitHub Actions permissions read-only by default.
## P1 Soon After Public
- Enable private vulnerability reporting.
- Decide whether Issues alone are enough or whether Discussions should be enabled for questions.
- Add repository topics such as `macos`, `swift`, `menubar`, `speech-to-text`, and `openai`.
- Add a lightweight release process only after the build is signed and notarized.
- Add basic tests once provider boundaries are extracted.
## P2 Later
- Add CODEOWNERS if multiple maintainers become active.
- Add local model cleanup after the in-app download/install flow.
- Consider CodeQL once the repo has enough surface area to justify scheduled scans.
- Add signed and notarized release artifacts for non-developer testers.

36
docs/privacy.md Normal file
View File

@ -0,0 +1,36 @@
# Privacy Notes
Blitztext macOS Preview does not include a hosted backend.
When you use the online workflows, your Mac sends data directly to OpenAI:
- audio recordings for transcription
- transcribed or typed text for rewriting
- custom terms and prompt context if you configured them
When **Sicherer Lokaler Modus** is enabled and a WhisperKit/CoreML model is installed, transcription runs on your Mac and does not send audio to OpenAI. Rewriting workflows still require OpenAI and are paused while secure local mode is active.
You are responsible for your OpenAI account, API usage, costs, and data handling.
## Local Data
The app stores:
- your OpenAI API key in the user's macOS Keychain
- workflow settings in local app support storage
- optional WhisperKit/CoreML model folders in local app support storage
- temporary audio files while a transcription is being processed; the app attempts to delete each recording when the workflow ends or is cancelled
Workflow output may also be placed on your clipboard so it can be pasted into another app. Auto-paste marks the clipboard entry as concealed for compatible clipboard managers and attempts to restore the previous clipboard content after paste. Clipboard managers, macOS, or other apps may still observe clipboard contents while they are present.
The app uses the system TLS trust store for OpenAI and Hugging Face requests. It does not currently pin certificates. A user-installed or managed root certificate can therefore affect HTTPS trust decisions on that Mac.
Settings such as custom prompts, custom terms, and context are stored in local app support storage as plain JSON. Do not put secrets into those fields.
## Offline Scope
Only transcription can run locally. Any workflow that rewrites, improves, or transforms text still uses OpenAI.
## Sensitive Content
Do not use this preview with confidential, regulated, or highly sensitive content unless you have reviewed the code, your OpenAI settings, and your legal/privacy requirements.

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

76
docs/setup.md Normal file
View File

@ -0,0 +1,76 @@
# Setup
This guide is for people who want to build and inspect the preview themselves.
## 1. Requirements
- macOS 14 or newer
- Full Xcode, with Command Line Tools installed
- XcodeGen
- Homebrew, if you want to install XcodeGen with `brew install xcodegen`
- Optional for online workflows: an OpenAI API key
- Optional for secure local transcription: a local WhisperKit/CoreML model
Install XcodeGen manually if needed:
```bash
brew install xcodegen
```
## 2. Clone And Build
```bash
git clone https://github.com/cmagnussen/blitztext-app.git
cd blitztext-app
./build.sh --debug
```
To launch after building:
```bash
./build.sh --run
```
## 3. Configure OpenAI For Online Workflows
Open the app settings and paste your own OpenAI API key if you want online transcription or rewriting workflows.
The preview currently uses:
- `whisper-1` for transcription
- `gpt-4o-mini` for lightweight rewriting
- `gpt-4o` for the calmer-message workflow
You are responsible for API access, billing, and data handling in your own OpenAI account.
Never commit your API key into this repository, issues, logs, or screenshots.
You can skip this step if you only want to test local transcription with a local WhisperKit model.
## 4. Optional Local Transcription
To use secure local transcription, choose a compatible WhisperKit CoreML model in the app and click **Installieren**. Blitztext stores models in:
```text
~/Library/Application Support/Blitztext/models/whisperkit/
```
Recommended first model: `openai_whisper-small_216MB`.
See [local-models.md](local-models.md) for the exact command, model links, and expected folder layout.
## 5. macOS Permissions
The app needs Microphone permission to record audio.
For automatic paste into the previous app, grant Accessibility permission in macOS System Settings. Without it, you can still copy and paste manually.
## Troubleshooting
- If `xcodebuild` reports that the active developer directory is only Command Line Tools, run `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`.
- If the build cannot find XcodeGen, install it explicitly with `brew install xcodegen`.
- If online transcription fails immediately, check whether the API key is present and valid.
- If secure local mode is disabled, check whether a WhisperKit model is installed in the expected folder.
- If paste does not work, check Accessibility permission.
- If audio is missing, check Microphone permission and macOS input settings.
- If you see OpenAI errors, verify model access and account billing.