blitztext-app-linux/BlitztextMac/App/AppState.swift
2026-05-23 07:28:46 +02:00

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