52e1e8288b
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore) - Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days - Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
12 KiB
Swift
249 lines
12 KiB
Swift
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<String> = ["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)
|
||
}
|
||
}
|
||
}
|