commit 45191f43989cebebcf15f27394ba7c206e6bb024 Author: cmagnussen Date: Thu May 21 14:16:04 2026 +0200 Initial open source preview diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ac9a222 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @cmagnussen diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ad2a0e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..51f3ff8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b7126ec --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc9a4d5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/secret-scan-patterns.txt b/.github/secret-scan-patterns.txt new file mode 100644 index 0000000..e1eaa97 --- /dev/null +++ b/.github/secret-scan-patterns.txt @@ -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----- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a5dd74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19a19fa --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/BlitztextMac/App/AppState.swift b/BlitztextMac/App/AppState.swift new file mode 100644 index 0000000..58cbd97 --- /dev/null +++ b/BlitztextMac/App/AppState.swift @@ -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? + private var workflowCleanupTask: Task? + private var pasteboardCleanupTask: Task? + + // 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(_ 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) + } +} diff --git a/BlitztextMac/App/BlitztextMacApp.swift b/BlitztextMac/App/BlitztextMacApp.swift new file mode 100644 index 0000000..04620b9 --- /dev/null +++ b/BlitztextMac/App/BlitztextMacApp.swift @@ -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 + } + } + } +} diff --git a/BlitztextMac/App/MenuBarStatusController.swift b/BlitztextMac/App/MenuBarStatusController.swift new file mode 100644 index 0000000..6767a88 --- /dev/null +++ b/BlitztextMac/App/MenuBarStatusController.swift @@ -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 + } +} diff --git a/BlitztextMac/Features/MenuBar/MenuBarView.swift b/BlitztextMac/Features/MenuBar/MenuBarView.swift new file mode 100644 index 0000000..5e20d11 --- /dev/null +++ b/BlitztextMac/Features/MenuBar/MenuBarView.swift @@ -0,0 +1,1024 @@ +import SwiftUI + +struct MenuBarView: View { + @Bindable var appState: AppState + + var body: some View { + VStack(spacing: 0) { + switch appState.page { + case .main: + mainPage + case .onboarding: + onboardingPage + case .settings: + settingsPage + case .workflow: + workflowPage + } + } + .frame(width: 340) + .animation(.easeInOut(duration: 0.2), value: appState.page) + } + + // MARK: - Main Page + + private var mainPage: some View { + VStack(spacing: 0) { + // Header + VStack(spacing: 0) { + // Top bar + HStack { + HStack(spacing: 6) { + Text("Blitztext") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + + Text("macOS Preview") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.quaternary) + } + + Spacer() + + Button { + appState.page = .settings + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "gear") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.00001)) // hit target + ) + .contentShape(Rectangle()) + + if !appState.accessibilityPermissionGranted { + Circle() + .fill(Color.orange) + .frame(width: 6, height: 6) + .offset(x: -4, y: 4) + } + } + } + .buttonStyle(SubtleButtonStyle()) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + // Status area + if appState.isConfigured { + configuredHeader + } else { + unconfiguredHeader + } + } + .padding(.bottom, 16) + .background( + Color(nsColor: .controlBackgroundColor).opacity(0.5) + ) + + if BlitztextInstallLocationService.shouldOfferMoveToApplications { + installHintBanner + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 6) + } + + transcriptionModePanel + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, appState.accessibilityPermissionGranted ? 6 : 4) + + if !appState.accessibilityPermissionGranted { + accessibilityHintBanner + .padding(.horizontal, 16) + .padding(.bottom, 6) + } + + // Workflow list + VStack(spacing: 0) { + ForEach(WorkflowType.mainMenuCases) { type in + let enabled = appState.isWorkflowAvailable(type) + WorkflowRowView( + type: type, + enabled: enabled, + customName: appState.displayName(for: type), + subtitle: appState.workflowSubtitle(for: type) + ) { + appState.startWorkflow(type) + } + } + } + .padding(.vertical, 2) + + appFooter + } + } + + private var transcriptionModePanel: some View { + let modelOptions = LocalTranscriptionService.modelOptions() + let selectedModelInstalled = appState.selectedLocalModelIsInstalled + + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: appState.appSettings.secureLocalModeEnabled ? "lock.shield.fill" : "network") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(appState.appSettings.secureLocalModeEnabled ? .green : .blue) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text(appState.appSettings.secureLocalModeEnabled ? "Sicherer lokaler Modus" : "Online Whisper") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.primary) + + Text(modePanelSubtitle(selectedModelInstalled: selectedModelInstalled)) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 4) + + Toggle("", isOn: Binding( + get: { appState.appSettings.secureLocalModeEnabled }, + set: { newValue in + if newValue { + appState.enableSecureLocalMode() + } else { + appState.appSettings.secureLocalModeEnabled = false + } + } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .disabled(appState.isDownloadingLocalModel) + } + + if appState.appSettings.secureLocalModeEnabled { + HStack(spacing: 8) { + Text("Modell") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + + Picker("", selection: Binding( + get: { appState.selectedLocalModelName }, + set: { appState.appSettings.selectedLocalTranscriptionModelName = $0 } + )) { + ForEach(modelOptions) { model in + Text(model.shortDisplayName).tag(model.id) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + .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 if !selectedModelInstalled { + Button(appState.localModelDownloadButtonTitle) { + appState.installSelectedLocalModel() + } + .controlSize(.small) + } + + if let errorText = appState.localModelDownloadErrorText { + Text(errorText) + .font(.system(size: 10.5)) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.primary.opacity(0.035)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + } + + private func modePanelSubtitle(selectedModelInstalled: Bool) -> String { + if appState.appSettings.secureLocalModeEnabled { + if appState.isDownloadingLocalModel { + return appState.localModelDownloadStatusText ?? "Lokales Modell wird geladen." + } + if selectedModelInstalled { + return "Lokal mit \(appState.selectedLocalModelDisplayName)." + } + return "\(appState.selectedLocalModelDisplayName) ist noch nicht installiert." + } + + return "Blitztext nutzt gerade die OpenAI-Transkription." + } + + private var accessibilityHintBanner: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "hand.raised.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 3) { + Text("Einfügen braucht Bedienungshilfen.") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Nach Updates kann macOS die Freigabe neu verlangen.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + + Button("Öffnen") { + appState.requestAccessibilityPermission() + } + .font(.system(size: 10.5, weight: .medium)) + .buttonStyle(SubtleButtonStyle()) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.orange.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5) + ) + } + + private var configuredHeader: some View { + HStack(spacing: 6) { + Circle() + .fill(.green) + .frame(width: 7, height: 7) + Text("Bereit") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + } + } + + private var installHintBanner: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "externaldrive.badge.plus") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 3) { + Text("Für sauberen Anmeldestart nach /Applications verschieben.") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Sonst entstehen leichter doppelte Login-Items oder uneinheitliche Updates.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + + Button("Prüfen") { + appState.page = .settings + } + .font(.system(size: 10.5, weight: .medium)) + .buttonStyle(SubtleButtonStyle()) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.orange.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5) + ) + } + + private var onboardingPage: some View { + VStack(spacing: 0) { + HStack { + Text("Willkommen bei Blitztext") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + + Spacer() + + Button("Später") { + appState.page = .main + } + .font(.system(size: 11, weight: .medium)) + .buttonStyle(SubtleButtonStyle()) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + .background( + Color(nsColor: .controlBackgroundColor).opacity(0.5) + ) + + Divider() + + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.blue.opacity(0.1)) + .frame(width: 42, height: 42) + Image(systemName: "sparkles") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Einmal einrichten, dann direkt loslegen.") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + Text("Eigenen OpenAI API Key eintragen. Danach sprechen und einfügen.") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(alignment: .leading, spacing: 10) { + if BlitztextInstallLocationService.shouldOfferMoveToApplications { + onboardingInstallCard + } + + onboardingStep(number: "1", title: "OpenAI Key speichern", detail: "Öffne die Einstellungen und trage deinen eigenen OpenAI API Key ein.") + onboardingStep(number: "2", title: "Berechtigungen erlauben", detail: "Mikrofon und Bedienungshilfen für das Einfügen freigeben.") + onboardingStep(number: "3", title: "Workflow wählen", detail: "Blitztext oder einen der Verbesserer-Workflows direkt aus der Menüleiste starten.") + } + + HStack(spacing: 8) { + Button { + appState.page = .settings + } label: { + Text("Jetzt einrichten") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + .buttonStyle(SubtleButtonStyle()) + + Text("Du findest alles später im Zahnrad.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + Spacer(minLength: 0) + + appFooter + } + } + + private var unconfiguredHeader: some View { + VStack(spacing: 10) { + ZStack { + Circle() + .fill(Color.orange.opacity(0.1)) + .frame(width: 40, height: 40) + Image(systemName: "key.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.orange) + } + + VStack(spacing: 4) { + Text("Einrichtung n\u{00F6}tig") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + + Text("\u{00D6}ffne die Einstellungen und hinterlege deine Zugangsdaten, um loszulegen.") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 20) + } + + Button { + appState.page = .settings + } label: { + Text("Einstellungen \u{00F6}ffnen") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .padding(.horizontal, 20) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + .buttonStyle(SubtleButtonStyle()) + } + } + + private func onboardingStep(number: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text(number) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .background( + Circle() + .fill(Color.primary.opacity(0.05)) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var onboardingInstallCard: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.down.app") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 4) { + Text("Lege Blitztext zuerst nach /Applications.") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.primary) + + Text("Das hält Anmeldestart, spätere Updates und das Entfernen sauber auf einer einzigen App-Kopie.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.orange.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.orange.opacity(0.12), lineWidth: 0.5) + ) + } + + // MARK: - Settings Page + + private var settingsPage: some View { + VStack(spacing: 0) { + // Header bar + HStack { + Button { + appState.page = .main + } label: { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + Text("Zur\u{00FC}ck") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(SubtleButtonStyle()) + + Spacer() + + Text("Einstellungen") + .font(.system(size: 12, weight: .semibold)) + + Spacer() + settingsQuickAction + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + SettingsContentView(appState: appState) + + Spacer(minLength: 0) + + appFooter + } + } + + @ViewBuilder + private var settingsQuickAction: some View { + if !appState.accessibilityPermissionGranted { + Button { + appState.requestAccessibilityPermission() + } label: { + HStack(spacing: 4) { + Image(systemName: "hand.raised") + .font(.system(size: 10, weight: .semibold)) + Text("Rechte") + .font(.system(size: 12)) + } + .foregroundStyle(.orange) + } + .buttonStyle(SubtleButtonStyle()) + } else { + Color.clear.frame(width: 58, height: 18) + } + } + + // MARK: - Workflow Page + + private var workflowPage: some View { + VStack(spacing: 0) { + if let workflow = appState.activeWorkflow { + // Header bar + HStack { + Button { + appState.resetCurrentWorkflow() + } label: { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + Text("Zur\u{00FC}ck") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(SubtleButtonStyle()) + + Spacer() + + HStack(spacing: 5) { + Image(systemName: workflow.type.icon) + .font(.system(size: 11)) + .foregroundStyle(workflowIconColor(workflow.type)) + Text(appState.displayName(for: workflow.type)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + // Content + switch workflow.type { + case .transcription, .localTranscription: + if let w = workflow as? TranscriptionWorkflow { + TranscriptionActiveView(workflow: w) + } + case .textImprover: + if let w = workflow as? TextImprovementWorkflow { + TextImproverActiveView(workflow: w) + } + case .dampfAblassen: + if let w = workflow as? DampfAblassenWorkflow { + DampfAblassenActiveView(workflow: w) + } + case .emojiText: + if let w = workflow as? EmojiTextWorkflow { + EmojiTextActiveView(workflow: w) + } + } + + Spacer(minLength: 0) + + appFooter + } + } + } + + private var appFooter: some View { + HStack { + Spacer() + Button("Beenden") { + NSApplication.shared.terminate(nil) + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.quaternary) + .buttonStyle(SubtleButtonStyle()) + Spacer() + } + .padding(.vertical, 8) + } + + private func workflowIconColor(_ type: WorkflowType) -> Color { + switch type { + case .transcription: return .blue + case .localTranscription: return .green + case .textImprover: return .purple + case .dampfAblassen: return .orange + case .emojiText: return .cyan + } + } +} + +// MARK: - Subtle Button Style + +struct SubtleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.5 : 1.0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +// MARK: - Transcription Active View + +struct TranscriptionActiveView: View { + @Bindable var workflow: TranscriptionWorkflow + + var body: some View { + VStack(spacing: 0) { + switch workflow.phase { + case .idle, .running: + if workflow.isRecording { + recordingView(onStop: { workflow.stop() }) + } else { + processingView(message: "Wird transkribiert \u{2026}") + } + + case .done(let text): + autoPasteView(text: text) + + case .error(let msg): + errorView(message: msg) { + workflow.reset() + workflow.start() + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + + @ViewBuilder + private func recordingView(onStop: @escaping () -> Void) -> some View { + VStack(spacing: 16) { + Spacer().frame(height: 20) + + WaveformView(audioLevel: workflow.audioLevel, isRecording: true) + .frame(height: 44) + .padding(.horizontal, 24) + + // Monochrome stop button + Button(action: onStop) { + ZStack { + Circle() + .strokeBorder(.primary.opacity(0.2), lineWidth: 1.5) + .frame(width: 44, height: 44) + RoundedRectangle(cornerRadius: 3) + .fill(.primary.opacity(0.7)) + .frame(width: 14, height: 14) + } + } + .buttonStyle(.plain) + + Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + Spacer().frame(height: 8) + } + } +} + +// MARK: - Text Improver Active View + +struct TextImproverActiveView: View { + @Bindable var workflow: TextImprovementWorkflow + + var body: some View { + VStack(spacing: 0) { + switch workflow.phase { + case .idle, .running: + if workflow.isRecording { + recordingView(onStop: { workflow.stop() }) + } else { + VStack(spacing: 12) { + Spacer().frame(height: 24) + ProgressView() + .scaleEffect(0.7) + .controlSize(.small) + if case .running(let msg) = workflow.phase { + Text(msg) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer().frame(height: 24) + } + } + + case .done(let text): + autoPasteView(text: text) + + case .error(let msg): + errorView(message: msg) { + workflow.reset() + workflow.start() + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + + @ViewBuilder + private func recordingView(onStop: @escaping () -> Void) -> some View { + VStack(spacing: 16) { + Spacer().frame(height: 20) + + WaveformView(audioLevel: workflow.audioLevel, isRecording: true) + .frame(height: 44) + .padding(.horizontal, 24) + + // Monochrome stop button + Button(action: onStop) { + ZStack { + Circle() + .strokeBorder(.primary.opacity(0.2), lineWidth: 1.5) + .frame(width: 44, height: 44) + RoundedRectangle(cornerRadius: 3) + .fill(.primary.opacity(0.7)) + .frame(width: 14, height: 14) + } + } + .buttonStyle(.plain) + + Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + Spacer().frame(height: 8) + } + } +} + +// MARK: - Rage Mode Active View + +struct DampfAblassenActiveView: View { + @Bindable var workflow: DampfAblassenWorkflow + + var body: some View { + VStack(spacing: 0) { + switch workflow.phase { + case .idle, .running: + if workflow.isRecording { + recordingView(onStop: { workflow.stop() }) + } else { + VStack(spacing: 12) { + Spacer().frame(height: 24) + ProgressView() + .scaleEffect(0.7) + .controlSize(.small) + if case .running(let msg) = workflow.phase { + Text(msg) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer().frame(height: 24) + } + } + + case .done(let text): + autoPasteView(text: text) + + case .error(let msg): + errorView(message: msg) { + workflow.reset() + workflow.start() + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + + @ViewBuilder + private func recordingView(onStop: @escaping () -> Void) -> some View { + VStack(spacing: 16) { + Spacer().frame(height: 20) + + WaveformView(audioLevel: workflow.audioLevel, isRecording: true) + .frame(height: 44) + .padding(.horizontal, 24) + + // Monochrome stop button + Button(action: onStop) { + ZStack { + Circle() + .strokeBorder(.primary.opacity(0.2), lineWidth: 1.5) + .frame(width: 44, height: 44) + RoundedRectangle(cornerRadius: 3) + .fill(.primary.opacity(0.7)) + .frame(width: 14, height: 14) + } + } + .buttonStyle(.plain) + + Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + Spacer().frame(height: 8) + } + } +} + +// MARK: - Emoji Text Active View + +struct EmojiTextActiveView: View { + @Bindable var workflow: EmojiTextWorkflow + + var body: some View { + VStack(spacing: 0) { + switch workflow.phase { + case .idle, .running: + if workflow.isRecording { + recordingView(onStop: { workflow.stop() }) + } else { + VStack(spacing: 12) { + Spacer().frame(height: 24) + ProgressView() + .scaleEffect(0.7) + .controlSize(.small) + if case .running(let msg) = workflow.phase { + Text(msg) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer().frame(height: 24) + } + } + + case .done(let text): + autoPasteView(text: text) + + case .error(let msg): + errorView(message: msg) { + workflow.reset() + workflow.start() + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + + @ViewBuilder + private func recordingView(onStop: @escaping () -> Void) -> some View { + VStack(spacing: 16) { + Spacer().frame(height: 20) + + WaveformView(audioLevel: workflow.audioLevel, isRecording: true) + .frame(height: 44) + .padding(.horizontal, 24) + + // Monochrome stop button + Button(action: onStop) { + ZStack { + Circle() + .strokeBorder(.primary.opacity(0.2), lineWidth: 1.5) + .frame(width: 44, height: 44) + RoundedRectangle(cornerRadius: 3) + .fill(.primary.opacity(0.7)) + .frame(width: 14, height: 14) + } + } + .buttonStyle(.plain) + + Text("Ich h\u{00F6}re zu \u{2026} Klicke zum Stoppen.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + Spacer().frame(height: 8) + } + } +} + +// MARK: - Shared Result / Error Views + +private func processingView(message: String) -> some View { + VStack(spacing: 12) { + Spacer().frame(height: 24) + ProgressView() + .scaleEffect(0.7) + .controlSize(.small) + Text(message) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + Spacer().frame(height: 24) + } +} + +private func autoPasteView(text: String) -> some View { + VStack(spacing: 12) { + Spacer().frame(height: 20) + + ZStack { + Circle() + .fill(Color.green.opacity(0.1)) + .frame(width: 44, height: 44) + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(.green) + } + + Text("Eingef\u{00FC}gt") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + + Text(text) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(3) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + + Spacer().frame(height: 12) + } +} + +private func errorView(message: String, onRetry: @escaping () -> Void) -> some View { + VStack(spacing: 10) { + Spacer().frame(height: 16) + + ZStack { + Circle() + .fill(Color.orange.opacity(0.1)) + .frame(width: 40, height: 40) + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 18)) + .foregroundStyle(.orange) + } + + Text(message) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + + Button(action: onRetry) { + Text("Nochmal versuchen") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + .buttonStyle(SubtleButtonStyle()) + + Spacer().frame(height: 4) + } +} diff --git a/BlitztextMac/Features/MenuBar/WorkflowRowView.swift b/BlitztextMac/Features/MenuBar/WorkflowRowView.swift new file mode 100644 index 0000000..3489963 --- /dev/null +++ b/BlitztextMac/Features/MenuBar/WorkflowRowView.swift @@ -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) + } +} diff --git a/BlitztextMac/Features/Settings/SettingsContentView.swift b/BlitztextMac/Features/Settings/SettingsContentView.swift new file mode 100644 index 0000000..aa3c321 --- /dev/null +++ b/BlitztextMac/Features/Settings/SettingsContentView.swift @@ -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)) + } +} diff --git a/BlitztextMac/Features/Workflows/DampfAblassenWorkflow.swift b/BlitztextMac/Features/Workflows/DampfAblassenWorkflow.swift new file mode 100644 index 0000000..5f5f75e --- /dev/null +++ b/BlitztextMac/Features/Workflows/DampfAblassenWorkflow.swift @@ -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? + + 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) + } + } + } +} diff --git a/BlitztextMac/Features/Workflows/EmojiTextWorkflow.swift b/BlitztextMac/Features/Workflows/EmojiTextWorkflow.swift new file mode 100644 index 0000000..8527ec0 --- /dev/null +++ b/BlitztextMac/Features/Workflows/EmojiTextWorkflow.swift @@ -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? + + 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) + } + } + } +} diff --git a/BlitztextMac/Features/Workflows/TextImprovementWorkflow.swift b/BlitztextMac/Features/Workflows/TextImprovementWorkflow.swift new file mode 100644 index 0000000..05f611c --- /dev/null +++ b/BlitztextMac/Features/Workflows/TextImprovementWorkflow.swift @@ -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? + + 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) + } + } + } +} diff --git a/BlitztextMac/Features/Workflows/TranscriptionWorkflow.swift b/BlitztextMac/Features/Workflows/TranscriptionWorkflow.swift new file mode 100644 index 0000000..9e34594 --- /dev/null +++ b/BlitztextMac/Features/Workflows/TranscriptionWorkflow.swift @@ -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? + + 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) + } + } + } +} diff --git a/BlitztextMac/Features/Workflows/WorkflowProtocol.swift b/BlitztextMac/Features/Workflows/WorkflowProtocol.swift new file mode 100644 index 0000000..926d53a --- /dev/null +++ b/BlitztextMac/Features/Workflows/WorkflowProtocol.swift @@ -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" + } + } + } +} diff --git a/BlitztextMac/Resources/AppIcon.icns b/BlitztextMac/Resources/AppIcon.icns new file mode 100644 index 0000000..8c2fa66 Binary files /dev/null and b/BlitztextMac/Resources/AppIcon.icns differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f9ff3b3 --- /dev/null +++ b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png new file mode 100644 index 0000000..f780010 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..8bcbfd1 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..7e4ca5d Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..773354d Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..cfd0f79 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..7e4ca5d Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..508ca91 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..cfd0f79 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..6982a1b Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..508ca91 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..f780010 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64.png new file mode 100644 index 0000000..6982a1b Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64@2x.png b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64@2x.png new file mode 100644 index 0000000..8bcbfd1 Binary files /dev/null and b/BlitztextMac/Resources/Assets.xcassets/AppIcon.appiconset/icon_64x64@2x.png differ diff --git a/BlitztextMac/Resources/Assets.xcassets/Contents.json b/BlitztextMac/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BlitztextMac/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BlitztextMac/Resources/BlitztextMac.entitlements b/BlitztextMac/Resources/BlitztextMac.entitlements new file mode 100644 index 0000000..010083b --- /dev/null +++ b/BlitztextMac/Resources/BlitztextMac.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.audio-input + + com.apple.security.network.client + + + diff --git a/BlitztextMac/Resources/Info.plist b/BlitztextMac/Resources/Info.plist new file mode 100644 index 0000000..c7334f9 --- /dev/null +++ b/BlitztextMac/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + NSMicrophoneUsageDescription + Blitztext benötigt Mikrofon-Zugriff für die Sprach-Transkription. + LSUIElement + + CFBundleIconFile + AppIcon + + diff --git a/BlitztextMac/Resources/menubar_icon.png b/BlitztextMac/Resources/menubar_icon.png new file mode 100644 index 0000000..64193b9 Binary files /dev/null and b/BlitztextMac/Resources/menubar_icon.png differ diff --git a/BlitztextMac/Resources/menubar_icon@2x.png b/BlitztextMac/Resources/menubar_icon@2x.png new file mode 100644 index 0000000..e8ed796 Binary files /dev/null and b/BlitztextMac/Resources/menubar_icon@2x.png differ diff --git a/BlitztextMac/Services/AccessibilityPermissionService.swift b/BlitztextMac/Services/AccessibilityPermissionService.swift new file mode 100644 index 0000000..fad144a --- /dev/null +++ b/BlitztextMac/Services/AccessibilityPermissionService.swift @@ -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) + } +} diff --git a/BlitztextMac/Services/AppSupportPaths.swift b/BlitztextMac/Services/AppSupportPaths.swift new file mode 100644 index 0000000..9bbe539 --- /dev/null +++ b/BlitztextMac/Services/AppSupportPaths.swift @@ -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 + ) + } +} diff --git a/BlitztextMac/Services/AudioRecorder.swift b/BlitztextMac/Services/AudioRecorder.swift new file mode 100644 index 0000000..629328a --- /dev/null +++ b/BlitztextMac/Services/AudioRecorder.swift @@ -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" + } + } + } +} diff --git a/BlitztextMac/Services/BlitztextCleanupService.swift b/BlitztextMac/Services/BlitztextCleanupService.swift new file mode 100644 index 0000000..12ea4be --- /dev/null +++ b/BlitztextMac/Services/BlitztextCleanupService.swift @@ -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) + } +} diff --git a/BlitztextMac/Services/BlitztextInstallLocationService.swift b/BlitztextMac/Services/BlitztextInstallLocationService.swift new file mode 100644 index 0000000..0a152dc --- /dev/null +++ b/BlitztextMac/Services/BlitztextInstallLocationService.swift @@ -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() + 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) + } +} diff --git a/BlitztextMac/Services/HotkeyService.swift b/BlitztextMac/Services/HotkeyService.swift new file mode 100644 index 0000000..a6177c5 --- /dev/null +++ b/BlitztextMac/Services/HotkeyService.swift @@ -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) + } +} diff --git a/BlitztextMac/Services/KeychainService.swift b/BlitztextMac/Services/KeychainService.swift new file mode 100644 index 0000000..897c629 --- /dev/null +++ b/BlitztextMac/Services/KeychainService.swift @@ -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)" + } + } +} diff --git a/BlitztextMac/Services/LLMService.swift b/BlitztextMac/Services/LLMService.swift new file mode 100644 index 0000000..3898d5b --- /dev/null +++ b/BlitztextMac/Services/LLMService.swift @@ -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 + } +} diff --git a/BlitztextMac/Services/LaunchAtLoginService.swift b/BlitztextMac/Services/LaunchAtLoginService.swift new file mode 100644 index 0000000..4695f3f --- /dev/null +++ b/BlitztextMac/Services/LaunchAtLoginService.swift @@ -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." + } + } +} diff --git a/BlitztextMac/Services/LocalTranscriptionService.swift b/BlitztextMac/Services/LocalTranscriptionService.swift new file mode 100644 index 0000000..4d30c1a --- /dev/null +++ b/BlitztextMac/Services/LocalTranscriptionService.swift @@ -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() + 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) + } +} diff --git a/BlitztextMac/Services/TranscriptionQualityService.swift b/BlitztextMac/Services/TranscriptionQualityService.swift new file mode 100644 index 0000000..35e2b08 --- /dev/null +++ b/BlitztextMac/Services/TranscriptionQualityService.swift @@ -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 + } +} diff --git a/BlitztextMac/Services/TranscriptionService.swift b/BlitztextMac/Services/TranscriptionService.swift new file mode 100644 index 0000000..9a82d8e --- /dev/null +++ b/BlitztextMac/Services/TranscriptionService.swift @@ -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) + } + } +} diff --git a/BlitztextMac/Views/WaveformView.swift b/BlitztextMac/Views/WaveformView.swift new file mode 100644 index 0000000..5c49fe9 --- /dev/null +++ b/BlitztextMac/Views/WaveformView.swift @@ -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) + } +} diff --git a/BlitztextMac/project.yml b/BlitztextMac/project.yml new file mode 100644 index 0000000..1baf37a --- /dev/null +++ b/BlitztextMac/project.yml @@ -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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..52fb73b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c7d68da --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e877fb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..219d1f6 --- /dev/null +++ b/README.md @@ -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 + + + + + + + + + + +
Blitztext online transcription modeBlitztext secure local transcription mode
Blitztext local model pickerBlitztext settings and customization view
+ +## 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). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..dff612c --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e9e7869 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..3721f21 --- /dev/null +++ b/SUPPORT.md @@ -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. diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000..bcc2958 --- /dev/null +++ b/TRADEMARKS.md @@ -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. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d6635da --- /dev/null +++ b/build.sh @@ -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 diff --git a/docs/github-settings.md b/docs/github-settings.md new file mode 100644 index 0000000..75bb1f1 --- /dev/null +++ b/docs/github-settings.md @@ -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. diff --git a/docs/landing-page-brief.md b/docs/landing-page-brief.md new file mode 100644 index 0000000..3836842 --- /dev/null +++ b/docs/landing-page-brief.md @@ -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. diff --git a/docs/local-models.md b/docs/local-models.md new file mode 100644 index 0000000..e9f9a94 --- /dev/null +++ b/docs/local-models.md @@ -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. diff --git a/docs/open-source-preflight.md b/docs/open-source-preflight.md new file mode 100644 index 0000000..51f4bee --- /dev/null +++ b/docs/open-source-preflight.md @@ -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. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..1047f1d --- /dev/null +++ b/docs/privacy.md @@ -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. diff --git a/docs/screenshots/local-mode.png b/docs/screenshots/local-mode.png new file mode 100644 index 0000000..923d91c Binary files /dev/null and b/docs/screenshots/local-mode.png differ diff --git a/docs/screenshots/local-model-picker.png b/docs/screenshots/local-model-picker.png new file mode 100644 index 0000000..33ff57b Binary files /dev/null and b/docs/screenshots/local-model-picker.png differ diff --git a/docs/screenshots/online-mode.png b/docs/screenshots/online-mode.png new file mode 100644 index 0000000..7046d7f Binary files /dev/null and b/docs/screenshots/online-mode.png differ diff --git a/docs/screenshots/settings-customize.png b/docs/screenshots/settings-customize.png new file mode 100644 index 0000000..c535eb2 Binary files /dev/null and b/docs/screenshots/settings-customize.png differ diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..ac969f4 --- /dev/null +++ b/docs/setup.md @@ -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.