619 lines
21 KiB
Swift
619 lines
21 KiB
Swift
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 concealedPasteboardType = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
|
|
|
|
var activeWorkflow: (any Workflow)?
|
|
var page: PopoverPage = .main
|
|
var isPopoverShown = false
|
|
var menuBarStatus: MenuBarStatus = .idle {
|
|
didSet {
|
|
guard oldValue != menuBarStatus else { return }
|
|
onMenuBarStatusChange?(menuBarStatus)
|
|
}
|
|
}
|
|
var accessibilityPermissionGranted = false
|
|
var localModelDownloadProgress: Double?
|
|
var localModelDownloadStatusText: String?
|
|
var localModelDownloadErrorText: String?
|
|
var onMenuBarStatusChange: ((MenuBarStatus) -> Void)?
|
|
private var activeLaunchSource: WorkflowLaunchSource = .manual
|
|
private var activePasteTarget: PasteTarget?
|
|
private var lastPopoverPasteTarget: PasteTarget?
|
|
private var menuBarStatusResetTask: Task<Void, Never>?
|
|
private var workflowCleanupTask: Task<Void, Never>?
|
|
|
|
// 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 text intentionally remains on the clipboard as a fallback if paste is blocked.
|
|
private func pasteAtCursor(_ text: String, target: PasteTarget? = nil) {
|
|
writeSensitiveTextToPasteboard(text)
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
private func writeSensitiveTextToPasteboard(_ text: String) {
|
|
let pasteboard = NSPasteboard.general
|
|
|
|
pasteboard.clearContents()
|
|
pasteboard.declareTypes([.string, Self.concealedPasteboardType], owner: nil)
|
|
pasteboard.setString(text, forType: .string)
|
|
pasteboard.setString("", forType: Self.concealedPasteboardType)
|
|
}
|
|
|
|
func prepareForPopoverPresentation() {
|
|
lastPopoverPasteTarget = captureCurrentFrontmostApp()
|
|
if let activeWorkflow, activeWorkflow.phase.isActive {
|
|
page = .workflow
|
|
} else if shouldShowOnboarding {
|
|
page = .onboarding
|
|
markOnboardingSeen()
|
|
} else if page == .workflow {
|
|
page = .main
|
|
} else if page == .onboarding {
|
|
page = .main
|
|
}
|
|
}
|
|
|
|
func markOnboardingSeen() {
|
|
guard !appSettings.hasSeenOnboarding else { return }
|
|
appSettings.hasSeenOnboarding = true
|
|
}
|
|
|
|
// MARK: - API Key Status
|
|
|
|
func apiKeyDisplayValue(for key: KeychainKey) -> String {
|
|
guard let value = KeychainService.load(key: key), !value.isEmpty else {
|
|
return ""
|
|
}
|
|
if value.count > 8 {
|
|
return String(value.prefix(4)) + " \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
|
|
}
|
|
return "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
|
|
}
|
|
|
|
func hasValue(for key: KeychainKey) -> Bool {
|
|
guard let value = KeychainService.load(key: key) else { return false }
|
|
return !value.isEmpty
|
|
}
|
|
|
|
// MARK: - Settings Persistence
|
|
|
|
private static let settingsURL: URL = {
|
|
try? AppSupportPaths.ensureAppSupportDirectoryExists()
|
|
return AppSupportPaths.settingsURL
|
|
}()
|
|
|
|
private func saveSettings() {
|
|
let container = SettingsContainer(
|
|
app: appSettings,
|
|
transcription: transcriptionSettings,
|
|
textImprovement: textImprovementSettings,
|
|
dampfAblassen: dampfAblassenSettings,
|
|
emojiText: emojiTextSettings
|
|
)
|
|
if let data = try? JSONEncoder().encode(container) {
|
|
try? data.write(to: Self.settingsURL)
|
|
}
|
|
}
|
|
|
|
private static func loadAppSettings() -> AppSettings {
|
|
loadContainer()?.app ?? AppSettings()
|
|
}
|
|
|
|
private static func loadTranscriptionSettings() -> TranscriptionSettings {
|
|
loadContainer()?.transcription ?? TranscriptionSettings()
|
|
}
|
|
|
|
private static func loadTextImprovementSettings() -> TextImprovementSettings {
|
|
loadContainer()?.textImprovement ?? TextImprovementSettings()
|
|
}
|
|
|
|
private static func loadDampfAblassenSettings() -> DampfAblassenSettings {
|
|
loadContainer()?.dampfAblassen ?? DampfAblassenSettings()
|
|
}
|
|
|
|
private static func loadEmojiTextSettings() -> EmojiTextSettings {
|
|
loadContainer()?.emojiText ?? EmojiTextSettings()
|
|
}
|
|
|
|
private static func loadContainer() -> SettingsContainer? {
|
|
guard let data = try? Data(contentsOf: settingsURL) else { return nil }
|
|
return try? JSONDecoder().decode(SettingsContainer.self, from: data)
|
|
}
|
|
|
|
func refreshAccessibilityPermission() {
|
|
accessibilityPermissionGranted = AccessibilityPermissionService.currentStatus()
|
|
}
|
|
|
|
func requestAccessibilityPermission() {
|
|
accessibilityPermissionGranted = AccessibilityPermissionService.requestPermissionPrompt()
|
|
AccessibilityPermissionService.openSystemSettings()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.refreshAccessibilityPermission()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
|
self?.refreshAccessibilityPermission()
|
|
}
|
|
}
|
|
|
|
private func autoSelectFastLocalModelIfNeeded() {
|
|
guard !appSettings.hasAutoSelectedFastLocalModel,
|
|
LocalTranscriptionService.shouldAutoSelectRecommendedFastModel(
|
|
currentModelName: appSettings.selectedLocalTranscriptionModelName
|
|
) else {
|
|
return
|
|
}
|
|
|
|
appSettings.selectedLocalTranscriptionModelName = LocalTranscriptionService.recommendedFastModelName
|
|
appSettings.hasAutoSelectedFastLocalModel = true
|
|
}
|
|
|
|
private func prewarmLocalTranscriptionIfNeeded() {
|
|
guard appSettings.secureLocalModeEnabled,
|
|
LocalTranscriptionService.isModelInstalled(resolvedLocalModelName) else {
|
|
return
|
|
}
|
|
|
|
let modelName = resolvedLocalModelName
|
|
Task.detached(priority: .utility) {
|
|
try? await LocalTranscriptionService.shared.prepare(modelName: modelName)
|
|
}
|
|
}
|
|
|
|
private func handleWorkflowOutput(_ text: String) {
|
|
pasteAtCursor(text, target: activePasteTarget)
|
|
if activeLaunchSource == .hotkeyBackground {
|
|
page = .main
|
|
}
|
|
scheduleWorkflowCleanup(after: 1.05)
|
|
}
|
|
|
|
private func configureWorkflowHandlers<T: Workflow>(_ workflow: T) {
|
|
workflow.onOutput = { [weak self] text in
|
|
self?.handleWorkflowOutput(text)
|
|
}
|
|
workflow.onPhaseChange = { [weak self, weak workflow] phase in
|
|
guard let self, let workflow else { return }
|
|
self.handleWorkflowPhaseChange(phase, workflow: workflow)
|
|
}
|
|
}
|
|
|
|
private func handleWorkflowPhaseChange(_ phase: WorkflowPhase, workflow: any Workflow) {
|
|
menuBarStatusResetTask?.cancel()
|
|
|
|
switch phase {
|
|
case .idle:
|
|
if activeWorkflow == nil {
|
|
menuBarStatus = .idle
|
|
}
|
|
|
|
case .running:
|
|
menuBarStatus = workflow.isRecording
|
|
? .recording(workflow.type)
|
|
: .processing(workflow.type)
|
|
|
|
case .done:
|
|
menuBarStatus = .success(workflow.type)
|
|
|
|
case .error:
|
|
menuBarStatus = .error(workflow.type)
|
|
if activeLaunchSource == .hotkeyBackground {
|
|
activeWorkflow = nil
|
|
activePasteTarget = nil
|
|
page = .main
|
|
}
|
|
scheduleMenuBarStatusReset(after: 1.6)
|
|
}
|
|
}
|
|
|
|
private func scheduleWorkflowCleanup(after delay: TimeInterval) {
|
|
guard let workflow = activeWorkflow else { return }
|
|
|
|
workflowCleanupTask?.cancel()
|
|
let workflowID = ObjectIdentifier(workflow)
|
|
|
|
workflowCleanupTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard let self, let activeWorkflow = self.activeWorkflow else { return }
|
|
guard ObjectIdentifier(activeWorkflow) == workflowID else { return }
|
|
|
|
activeWorkflow.reset()
|
|
self.activeWorkflow = nil
|
|
self.activePasteTarget = nil
|
|
self.activeLaunchSource = .manual
|
|
if !self.isPopoverShown {
|
|
self.page = .main
|
|
}
|
|
self.menuBarStatus = .idle
|
|
}
|
|
}
|
|
|
|
private func scheduleMenuBarStatusReset(after delay: TimeInterval) {
|
|
menuBarStatusResetTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard let self else { return }
|
|
if self.activeWorkflow == nil || !(self.activeWorkflow?.phase.isActive ?? false) {
|
|
self.menuBarStatus = .idle
|
|
}
|
|
}
|
|
}
|
|
|
|
private func capturePasteTarget(for source: WorkflowLaunchSource) -> PasteTarget? {
|
|
switch source {
|
|
case .manual:
|
|
return lastPopoverPasteTarget
|
|
case .hotkeyBackground:
|
|
return captureCurrentFrontmostApp()
|
|
}
|
|
}
|
|
|
|
private func attemptPasteTrusted(
|
|
target: PasteTarget?,
|
|
attemptsRemaining: Int
|
|
) {
|
|
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
|
|
if let target {
|
|
if frontmostPid == target.processIdentifier {
|
|
performPaste()
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|