import SwiftUI import AppKit import Translation // MARK: - Translator (Func → Переводчик, ⌃⌥R) // // Takes the text selected in the front app (Accessibility; falls back to a simulated ⌘C // with the clipboard restored afterwards; then to whatever is already on the clipboard), // detects the direction (Cyrillic → English, anything else → Russian) and translates it // with the on-device macOS Translation when that language pair is installed, otherwise // with `claude` (Haiku). The result shows as a card in the notch's home view. struct TranslationResult: Equatable { var source: String var text: String = "" var toRussian: Bool var engine: String = "" var error: String? = nil var loading = true } @MainActor enum TranslatorRun { static func start(state: AppState) { Task { @MainActor in guard let source = await SelectionGrabber.grab(), !source.isEmpty else { state.translation = TranslationResult(source: "", toRussian: true, error: SelectionGrabber.trusted ? "Не нашёл выделенного текста. Выдели текст в приложении и нажми ⌃⌥R." : "Дай BroV доступ: Системные настройки → Конфиденциальность → Универсальный доступ → BroV. Потом выдели текст и нажми ⌃⌥R.", loading: false) show(state) return } let toRussian = !Self.isMostlyCyrillic(source) state.translation = TranslationResult(source: source, toRussian: toRussian) show(state) do { let (text, engine) = try await Translator.translate(source, toRussian: toRussian) guard state.translation?.source == source else { return } state.translation?.text = text state.translation?.engine = engine state.translation?.loading = false SoundEngine.shared.play("pop") } catch { guard state.translation?.source == source else { return } state.translation?.error = error.localizedDescription state.translation?.loading = false } } } private static func show(_ state: AppState) { if state.mode != .expanded || !(state.view == .overview || state.view == .empty) { NotificationCenter.default.post(name: .hookExpand, object: IslandView.overview) } } static func isMostlyCyrillic(_ s: String) -> Bool { var cyr = 0, lat = 0 for u in s.unicodeScalars { if (0x0400...0x04FF).contains(u.value) { cyr += 1 } else if CharacterSet.letters.contains(u), u.isASCII { lat += 1 } } return cyr > lat } } enum Translator { static func translate(_ text: String, toRussian: Bool) async throws -> (String, String) { let ru = Locale.Language(identifier: "ru") let en = Locale.Language(identifier: "en") if #available(macOS 26.0, *) { let target = toRussian ? ru : en // Source: English when going to Russian (the common case), else Russian. let source = toRussian ? en : ru let status = await LanguageAvailability().status(from: source, to: target) if status == .installed { let session = TranslationSession(installedSource: source, target: target) if let r = try? await session.translate(text) { return (r.targetText, "macOS") } } } return (try await viaClaude(text, toRussian: toRussian), "Claude Haiku") } private static func scratchDir() -> String { let dir = FileManager.default.temporaryDirectory.appendingPathComponent("brov-translate") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.path } private static func viaClaude(_ text: String, toRussian: Bool) async throws -> String { let instruction = toRussian ? "Translate the text below into Russian. Output only the translation, keep formatting." : "Переведи текст ниже на английский. Выведи только перевод, сохрани форматирование." let cli = ClaudeCodeCLI() let state = await AppState.shared let out = try await cli.send(prompt: instruction + "\n\n" + text, model: "haiku", // An empty temp folder: no file access prompts, nothing to read. workingDirectory: Self.scratchDir(), binaryPath: await state.claudeBinaryPath, permissionMode: "plan") { _ in } return out.trimmingCharacters(in: .whitespacesAndNewlines) } } // MARK: - Selected text of the front app @MainActor enum SelectionGrabber { static var trusted: Bool { AXIsProcessTrusted() } static func grab() async -> String? { if !trusted { // Shows the system prompt once; meanwhile use the clipboard. let opts = ["AXTrustedCheckOptionPrompt": true] as CFDictionary _ = AXIsProcessTrustedWithOptions(opts) return clipboardText() } if let s = axSelection(), !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return s } // ⌘C only when BroV isn't the key window (hotkey path); from a click in the notch // the keystroke would land in BroV itself. if NSApp.keyWindow == nil, let s = await copyViaCommandC(), !s.isEmpty { return s } return clipboardText() } private static func axSelection() -> String? { let system = AXUIElementCreateSystemWide() var focused: CFTypeRef? guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focused) == .success, let el = focused else { return nil } var value: CFTypeRef? guard AXUIElementCopyAttributeValue(el as! AXUIElement, kAXSelectedTextAttribute as CFString, &value) == .success else { return nil } return value as? String } /// Simulates ⌘C in the front app, reads the copy, then puts the old clipboard back. private static func copyViaCommandC() async -> String? { let pb = NSPasteboard.general let before = pb.changeCount let saved = pb.pasteboardItems?.map { item -> [NSPasteboard.PasteboardType: Data] in var d: [NSPasteboard.PasteboardType: Data] = [:] for t in item.types { if let v = item.data(forType: t) { d[t] = v } } return d } ?? [] let src = CGEventSource(stateID: .combinedSessionState) let down = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: true) // C down?.flags = .maskCommand let up = CGEvent(keyboardEventSource: src, virtualKey: 8, keyDown: false) up?.flags = .maskCommand down?.post(tap: .cghidEventTap) up?.post(tap: .cghidEventTap) for _ in 0..<10 { try? await Task.sleep(for: .milliseconds(30)) if pb.changeCount != before { break } } guard pb.changeCount != before else { return nil } let text = pb.string(forType: .string) // Restore what the user had copied — except secrets: password managers mark them // concealed/transient and clear them on their own; putting them back would defeat that. pb.clearContents() let secretTypes: Set = ["org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType", "org.nspasteboard.AutoGeneratedType", "com.agilebits.onepassword"] if saved.contains(where: { $0.keys.contains { secretTypes.contains($0.rawValue) } }) { return text } let items = saved.map { dict -> NSPasteboardItem in let it = NSPasteboardItem() for (t, v) in dict { it.setData(v, forType: t) } return it } if !items.isEmpty { pb.writeObjects(items) } return text } private static func clipboardText() -> String? { NSPasteboard.general.string(forType: .string) } } // MARK: - Card struct TranslationCard: View { @ObservedObject var state: AppState let result: TranslationResult var body: some View { ZStack(alignment: .topTrailing) { CardBackground(wash: nil) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Image(systemName: "character.bubble").font(.system(size: 11, weight: .semibold)) Text(result.error != nil ? "Перевод" : (result.toRussian ? "Перевод на русский" : "Перевод на английский")) .font(.system(size: 11.5, weight: .semibold)) if !result.engine.isEmpty { Text("· \(result.engine)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#6B7079")) } } .foregroundColor(Color(hex: "#B0B5BE")) if let err = result.error { Text(err).font(.system(size: 11.5)).foregroundColor(Color(hex: "#FB923C")) .fixedSize(horizontal: false, vertical: true) } else if result.loading { HStack(spacing: 6) { ProgressView().controlSize(.small) Text(result.source).lineLimit(1).font(.system(size: 11.5)).foregroundColor(Color(hex: "#6B7079")) } } else { ScrollView(.vertical, showsIndicators: false) { Text(result.text) .font(.system(size: 12.5)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } HStack(spacing: 8) { FuncButton(symbol: "doc.on.doc", title: "Скопировать") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(result.text, forType: .string) SoundEngine.shared.play("blip") } FuncButton(symbol: "bubble.left", title: "В чат") { if state.tabs.count < AppState.maxTabs { state.openTab(.chat) } else { state.view = .prompt } if let tab = state.activeTabId { ChatTabs.shared.seed(tab, "Исходный текст:\n\(result.source)\n\nПеревод:\n\(result.text)\n\nВопросы ниже — про этот перевод.") } state.chatHistory.append(ChatMessage(role: .assistant, content: "**Перевод:**\n\n\(result.text)")) state.translation = nil } Text(result.source).lineLimit(1).font(.system(size: 10.5)).foregroundColor(Color(hex: "#5F646D")) } } } .padding(.leading, 104) .padding(.trailing, 36) .padding(.vertical, 8) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) Button { withAnimation(.easeOut(duration: 0.18)) { state.translation = nil } } label: { Image(systemName: "xmark").font(.system(size: 8, weight: .bold)) .foregroundColor(Color(hex: "#8E939C")) .frame(width: 18, height: 18) .background(Circle().fill(Color.white.opacity(0.08))) } .buttonStyle(.plain) .padding(10) } } }