feat: rewrite TTREE into 100% pure-Rust modular workspace with native AppKit UI, fast hedged translation, reliable OCR, and Ollama/LMStudio/GGUF AI backends

This commit is contained in:
ZASENJC committed 2026-10-14 19:07:43 +08:00
1 parent fd8c685baa
commit 0f04fefbcc
39 files changed
+11521

No files matched your search

+2
View File
@@ -10,3 +10,5 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/target
Generated
+3773
View File
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
[workspace]
resolver = "2"
members = [
"crates/ttree-core",
"crates/ttree-storage",
"crates/ttree-platform-macos",
"crates/ttree-ocr",
"crates/ttree-translate",
"crates/ttree-ai",
"crates/ttree-app",
]
[workspace.package]
version = "0.2.0"
edition = "2021"
authors = ["ZASENJC"]
license = "MIT"
[workspace.dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream", "cookies"] }
futures-util = "0.3"
url = "2"
urlencoding = "2"
tempfile = "3"
dirs = "6"
auto-launch = "0.5"
global-hotkey = "0.8"
core-foundation = "0.10"
security-framework = "3"
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = [
"NSArray",
"NSAttributedString",
"NSBundle",
"NSData",
"NSDictionary",
"NSError",
"NSGeometry",
"NSObject",
"NSProcessInfo",
"NSRange",
"NSRunLoop",
"NSString",
"NSThread",
"NSTimer",
"NSURL",
"NSValue",
] }
objc2-core-foundation = "0.3"
objc2-core-graphics = { version = "0.3", features = [
"CGBitmapContext",
"CGColorSpace",
"CGContext",
"CGEvent",
"CGEventTypes",
"CGImage",
"CGRemoteOperation",
] }
objc2-core-image = { version = "0.3", features = ["CIContext"] }
objc2-core-ml = { version = "0.3", features = ["MLCPUComputeDevice", "MLComputeDeviceProtocol"] }
objc2-image-io = { version = "0.3", features = ["CGImageSource"] }
objc2-vision = { version = "0.3", features = [
"objc2-core-graphics",
"objc2-core-ml",
"VNRequest",
"VNRecognizeTextRequest",
"VNObservation",
"VNRequestHandler",
"VNTypes",
] }
objc2-app-kit = { version = "0.3", features = [
"NSApplication",
"NSBezierPath",
"NSBox",
"NSButton",
"NSButtonCell",
"NSCell",
"NSColor",
"NSControl",
"NSEvent",
"NSFont",
"NSGraphics",
"NSImage",
"NSMenu",
"NSMenuItem",
"NSOpenPanel",
"NSPanel",
"NSPasteboard",
"NSPasteboardItem",
"NSPopUpButton",
"NSResponder",
"NSRunningApplication",
"NSSavePanel",
"NSScreen",
"NSScrollView",
"NSSecureTextField",
"NSSegmentedControl",
"NSSlider",
"NSStatusBar",
"NSStatusBarButton",
"NSStatusItem",
"NSText",
"NSTextContainer",
"NSTextField",
"NSTextFieldCell",
"NSTextStorage",
"NSTextView",
"NSView",
"NSVisualEffectView",
"NSWindow",
"objc2-core-foundation",
] }
objc2-quartz-core = { version = "0.3", features = ["CALayer"] }
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ttree-ai"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
ttree-core = { path = "../ttree-core" }
anyhow.workspace = true
futures-util.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
url.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
candle-core = { version = "0.11.0", features = ["metal", "accelerate"] }
candle-transformers = { version = "0.11.0", features = ["metal", "accelerate"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
candle-core = { version = "0.11.0" }
candle-transformers = { version = "0.11.0" }
+396
View File
@@ -0,0 +1,396 @@
//! 纯 Rust `Candle` 本地 `.gguf` 量化模型直接加载与流式推理(支持 macOS Metal GPU 加速)。
//!
//! 核心亮点:
//! 1. 直接从 `.gguf` 文件内置的 `tokenizer.ggml.tokens` 元数据构建分词器与解码器,无需额外准备 `tokenizer.json`;
//! 2. 自动识别 `qwen2`、`qwen3`、`llama`(含 Llama3 / Mistral / DeepSeek 等)主流 GGUF 架构;
//! 3. 优先启用 Apple Silicon `Device::new_metal(0)` GPU 加速,并在内存中常驻缓存已加载的模型权重,二次翻译 0 加载延迟;
//! 4. 每生成一个 Token 即刻回调 UI 并检查 `CancelToken`,支持随时毫秒级打断。
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use candle_core::quantized::gguf_file::{self, Value as GgufValue};
use candle_core::{Device, Tensor};
use candle_transformers::models::{quantized_llama, quantized_qwen2, quantized_qwen3};
use ttree_core::{CancelToken, ChatMessage};
const MAX_NEW_TOKENS: usize = 512;
enum QuantizedArch {
Qwen2(quantized_qwen2::ModelWeights),
Qwen3(quantized_qwen3::ModelWeights),
Llama(quantized_llama::ModelWeights),
}
impl QuantizedArch {
fn clear_kv_cache(&mut self) {
match self {
Self::Qwen2(m) => m.clear_kv_cache(),
Self::Qwen3(m) => m.clear_kv_cache(),
Self::Llama(_) => {}
}
}
fn forward(&mut self, input: &Tensor, index_pos: usize) -> candle_core::Result<Tensor> {
match self {
Self::Qwen2(m) => m.forward(input, index_pos),
Self::Qwen3(m) => m.forward(input, index_pos),
Self::Llama(m) => m.forward(input, index_pos),
}
}
}
/// 从 GGUF 内嵌的 `tokenizer.ggml.tokens` 词表构建的轻量分词/解码器。
pub struct GgufEmbeddedTokenizer {
id_to_token: Vec<String>,
token_to_id: HashMap<String, u32>,
eos_tokens: Vec<u32>,
bos_token: Option<u32>,
}
impl GgufEmbeddedTokenizer {
pub fn from_gguf_metadata(md: &HashMap<String, GgufValue>) -> Result<Self, String> {
let raw_tokens = md
.get("tokenizer.ggml.tokens")
.and_then(|v| v.to_vec().ok())
.ok_or_else(|| "GGUF 文件缺少内置词表 (tokenizer.ggml.tokens)".to_string())?;
let mut id_to_token = Vec::with_capacity(raw_tokens.len());
let mut token_to_id = HashMap::with_capacity(raw_tokens.len());
for (idx, val) in raw_tokens.iter().enumerate() {
let s = val.to_string().map(|s| s.clone()).unwrap_or_default();
token_to_id.insert(s.clone(), idx as u32);
id_to_token.push(s);
}
let mut eos_tokens = Vec::new();
if let Some(eos_id) = md
.get("tokenizer.ggml.eos_token_id")
.and_then(|v| v.to_u32().ok())
{
eos_tokens.push(eos_id);
}
for special in ["<|im_end|>", "<|endoftext|>", "</s>", "<|eot_id|>", "<end_of_turn>"] {
if let Some(&id) = token_to_id.get(special) {
if !eos_tokens.contains(&id) {
eos_tokens.push(id);
}
}
}
let bos_token = md
.get("tokenizer.ggml.bos_token_id")
.and_then(|v| v.to_u32().ok());
Ok(Self {
id_to_token,
token_to_id,
eos_tokens,
bos_token,
})
}
pub fn is_eos(&self, token_id: u32) -> bool {
self.eos_tokens.contains(&token_id)
}
/// 最长前缀贪心匹配 + GPT2/SentencePiece 字节回退分词。
pub fn encode(&self, text: &str) -> Vec<u32> {
let mut ids = Vec::new();
if let Some(bos) = self.bos_token {
ids.push(bos);
}
// 将空格映射为 SentencePiece `▁` 或 GPT-2 `Ġ` 候选以匹配主流 GGUF 词表
let uses_sp_space = self.token_to_id.contains_key("▁the")
|| self.token_to_id.contains_key("▁")
|| self.token_to_id.contains_key("▁a");
let normalized = if uses_sp_space {
text.replace(' ', "▁")
} else {
text.replace(' ', "Ġ").replace('\n', "Ċ")
};
let mut rest = normalized.as_str();
while !rest.is_empty() {
// 先匹配特殊控制标记(如 `<|im_start|>`、`<|im_end|>`)
if rest.starts_with("<|") {
if let Some(end_idx) = rest.find("|>") {
let special = &rest[..end_idx + 2];
if let Some(&id) = self.token_to_id.get(special) {
ids.push(id);
rest = &rest[end_idx + 2..];
continue;
}
}
}
let mut matched = None;
let max_bytes = rest.len().min(32);
let mut end = max_bytes;
while end > 0 {
if rest.is_char_boundary(end) {
let candidate = &rest[..end];
if let Some(&id) = self.token_to_id.get(candidate) {
matched = Some((id, end));
break;
}
}
end -= 1;
}
if let Some((id, len)) = matched {
ids.push(id);
rest = &rest[len..];
} else {
// 单字节 `<0xXX>` 回退
let ch = rest.chars().next().unwrap();
let ch_len = ch.len_utf8();
for &b in &rest.as_bytes()[..ch_len] {
let hex_tok = format!("<0x{b:02X}>");
if let Some(&id) = self.token_to_id.get(&hex_tok) {
ids.push(id);
}
}
rest = &rest[ch_len..];
}
}
ids
}
pub fn decode_token_bytes(&self, token_id: u32) -> Vec<u8> {
let Some(piece) = self.id_to_token.get(token_id as usize) else {
return Vec::new();
};
if piece.starts_with("<|") && piece.ends_with("|>") {
return Vec::new();
}
if piece.len() == 6 && piece.starts_with("<0x") && piece.ends_with('>') {
if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
return vec![b];
}
}
let decoded = piece
.replace('▁', " ")
.replace("Ġ", " ")
.replace("Ċ", "\n");
decoded.into_bytes()
}
}
struct CachedSession {
model_path: String,
device: Device,
arch: QuantizedArch,
tokenizer: GgufEmbeddedTokenizer,
}
static GGUF_SESSION: OnceLock<Mutex<Option<CachedSession>>> = OnceLock::new();
fn select_device() -> Device {
#[cfg(target_os = "macos")]
if let Ok(metal) = Device::new_metal(0) {
return metal;
}
Device::Cpu
}
fn load_session(model_path: &str) -> Result<CachedSession, String> {
let path = Path::new(model_path);
if !path.is_file() {
return Err(format!("找不到本地 GGUF 模型文件: {model_path}"));
}
let file = File::open(path).map_err(|e| format!("打开 GGUF 文件失败: {e}"))?;
let mut reader = BufReader::new(file);
let content = gguf_file::Content::read(&mut reader)
.map_err(|e| format!("解析 GGUF 文件头失败: {e}"))?;
let arch_name = content
.metadata
.get("general.architecture")
.and_then(|v| v.to_string().ok())
.cloned()
.unwrap_or_else(|| "llama".to_string());
let tokenizer = GgufEmbeddedTokenizer::from_gguf_metadata(&content.metadata)?;
let device = select_device();
let arch = match arch_name.as_str() {
"qwen2" => {
let weights = quantized_qwen2::ModelWeights::from_gguf(content, &mut reader, &device)
.map_err(|e| format!("加载 Qwen2 GGUF 权重失败: {e}"))?;
QuantizedArch::Qwen2(weights)
}
"qwen3" => {
let weights = quantized_qwen3::ModelWeights::from_gguf(content, &mut reader, &device)
.map_err(|e| format!("加载 Qwen3 GGUF 权重失败: {e}"))?;
QuantizedArch::Qwen3(weights)
}
_ => {
let weights = quantized_llama::ModelWeights::from_gguf(content, &mut reader, &device)
.map_err(|e| format!("加载 {arch_name} GGUF 权重失败: {e}"))?;
QuantizedArch::Llama(weights)
}
};
Ok(CachedSession {
model_path: model_path.to_string(),
device,
arch,
tokenizer,
})
}
/// 将对话消息格式化为通用 ChatML 提示词模板(兼容 Qwen / Llama / OpenChat 等 GGUF 模型)。
pub fn format_chatml_prompt(messages: &[ChatMessage]) -> String {
let mut prompt = String::new();
for msg in messages {
prompt.push_str("<|im_start|>");
prompt.push_str(&msg.role);
prompt.push('\n');
prompt.push_str(&msg.content);
prompt.push_str("<|im_end|>\n");
}
prompt.push_str("<|im_start|>assistant\n");
prompt
}
pub async fn stream_gguf_inference<F>(
gguf_model_path: &str,
_gguf_tokenizer_path: &str,
messages: &[ChatMessage],
cancel: &CancelToken,
on_delta: &mut F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
let model_path = gguf_model_path.trim().to_string();
if model_path.is_empty() {
return Err("未选择本地 .gguf 模型文件路径,请在设置中指定文件位置".to_string());
}
let prompt = format_chatml_prompt(messages);
let cancel_clone = cancel.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Result<String, String>>();
tokio::task::spawn_blocking(move || {
let slot = GGUF_SESSION.get_or_init(|| Mutex::new(None));
let mut guard = match slot.lock() {
Ok(g) => g,
Err(e) => {
let _ = tx.send(Err(format!("GGUF 引擎锁异常: {e}")));
return;
}
};
let needs_reload = guard
.as_ref()
.is_none_or(|s| s.model_path != model_path);
if needs_reload {
match load_session(&model_path) {
Ok(session) => *guard = Some(session),
Err(err) => {
let _ = tx.send(Err(err));
return;
}
}
}
let session = guard.as_mut().expect("session loaded");
session.arch.clear_kv_cache();
let prompt_tokens = session.tokenizer.encode(&prompt);
if prompt_tokens.is_empty() {
let _ = tx.send(Err("提示词分词为空".to_string()));
return;
}
let mut next_token = match run_step(&mut session.arch, &session.device, &prompt_tokens, 0) {
Ok(tok) => tok,
Err(err) => {
let _ = tx.send(Err(err));
return;
}
};
let mut utf8_pending = Vec::new();
let mut pos = prompt_tokens.len();
for _ in 0..MAX_NEW_TOKENS {
if cancel_clone.is_cancelled() {
return;
}
if session.tokenizer.is_eos(next_token) {
break;
}
utf8_pending.extend_from_slice(&session.tokenizer.decode_token_bytes(next_token));
if let Ok(valid_str) = std::str::from_utf8(&utf8_pending) {
if !valid_str.is_empty() {
let _ = tx.send(Ok(valid_str.to_string()));
}
utf8_pending.clear();
}
next_token = match run_step(&mut session.arch, &session.device, &[next_token], pos) {
Ok(tok) => tok,
Err(err) => {
let _ = tx.send(Err(err));
return;
}
};
pos += 1;
}
});
while let Some(item) = rx.recv().await {
if cancel.is_cancelled() {
return Err("请求已取消".to_string());
}
let delta = item?;
on_delta(&delta);
}
Ok(())
}
/// 执行一步前向推理并采用 Argmax 贪心采样(翻译任务使用 temperature=0 最准确稳定)。
fn run_step(
arch: &mut QuantizedArch,
device: &Device,
tokens: &[u32],
index_pos: usize,
) -> Result<u32, String> {
let input = Tensor::new(tokens, device)
.and_then(|t| t.unsqueeze(0))
.map_err(|e| format!("构造输入张量失败: {e}"))?;
let logits = arch
.forward(&input, index_pos)
.map_err(|e| format!("GGUF 模型前向推理失败: {e}"))?;
let logits = logits
.squeeze(0)
.map_err(|e| format!("张量维度压缩失败: {e}"))?;
let next_id = logits
.argmax(0)
.and_then(|t| t.to_scalar::<u32>())
.map_err(|e| format!("采样下一个 Token 失败: {e}"))?;
Ok(next_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_chatml_prompt_correctly() {
let msgs = vec![
ChatMessage::system("Translate to Chinese."),
ChatMessage::user("Hello"),
];
let p = format_chatml_prompt(&msgs);
assert!(p.contains("<|im_start|>system\nTranslate to Chinese.<|im_end|>"));
assert!(p.ends_with("<|im_start|>assistant\n"));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! 云端 OpenAI 兼容接口与本地服务(Ollama / LM Studio / MLX Server)HTTP 流式客户端 + 本地模型自动探测。
use std::sync::OnceLock;
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use ttree_core::{AiBackendKind, CancelToken, ChatMessage};
use crate::sse::{LineStreamDecoder, StreamDelta};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const STREAM_TIMEOUT: Duration = Duration::from_secs(90);
fn http_client() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(STREAM_TIMEOUT)
.tcp_keepalive(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build AI HTTP client")
})
}
/// 自动探测本机运行中的 Ollama (`/api/tags`) 或 LM Studio / MLX (`/v1/models`) 模型列表。
pub async fn discover_local_models(
backend: AiBackendKind,
base_url: &str,
) -> Result<Vec<String>, String> {
let base = if base_url.trim().is_empty() {
backend.default_base_url()
} else {
base_url.trim().trim_end_matches('/')
};
match backend {
AiBackendKind::Ollama => {
let root = base.trim_end_matches("/v1");
let url = format!("{root}/api/tags");
let resp = http_client()
.get(&url)
.timeout(Duration::from_secs(3))
.send()
.await
.map_err(|e| format!("无法连接本机 Ollama 服务 ({url}): {e}"))?;
if !resp.status().is_success() {
return Err(format!("Ollama 返回状态 {}", resp.status()));
}
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析 Ollama 模型列表失败: {e}"))?;
let models = body
.get("models")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|item| item.get("name").and_then(Value::as_str).map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(models)
}
AiBackendKind::LocalServer | AiBackendKind::OpenAiCompatible => {
let root = if base.ends_with("/v1") {
base.to_string()
} else {
format!("{base}/v1")
};
let url = format!("{root}/models");
let resp = http_client()
.get(&url)
.timeout(Duration::from_secs(3))
.send()
.await
.map_err(|e| format!("无法连接本地模型服务 ({url}): {e}"))?;
if !resp.status().is_success() {
return Err(format!("模型服务返回状态 {}", resp.status()));
}
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析模型列表失败: {e}"))?;
let models = body
.get("data")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|item| item.get("id").and_then(Value::as_str).map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(models)
}
AiBackendKind::CandleGguf => Ok(Vec::new()),
}
}
/// 调用 OpenAI 兼容端点(云端或 LM Studio / MLX / llama.cpp)执行 SSE 流式请求。
pub async fn stream_openai_compatible<F>(
base_url: &str,
api_key: Option<&str>,
model: &str,
messages: &[ChatMessage],
cancel: &CancelToken,
on_delta: &mut F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
let base = base_url.trim().trim_end_matches('/');
let url = if base.ends_with("/chat/completions") {
base.to_string()
} else {
format!("{base}/chat/completions")
};
let payload = json!({
"model": model,
"stream": true,
"temperature": 0.2,
"messages": messages,
});
let mut req = http_client().post(&url).json(&payload);
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
req = req.bearer_auth(key);
}
let resp = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
r = req.send() => r.map_err(|e| format!("AI 请求失败: {e}"))?,
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let snippet: String = text.chars().take(300).collect();
return Err(format!("AI 接口返回状态 {status}: {snippet}"));
}
let mut stream = resp.bytes_stream();
let mut decoder = LineStreamDecoder::new_sse();
loop {
let next_chunk = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
chunk = stream.next() => chunk,
};
let Some(chunk) = next_chunk else {
break;
};
let bytes = chunk.map_err(|e| format!("读取 AI 响应流失败: {e}"))?;
for delta in decoder.push(&bytes)? {
match delta {
StreamDelta::Content(text) => on_delta(&text),
StreamDelta::Done => return Ok(()),
}
}
}
for delta in decoder.finish()? {
if let StreamDelta::Content(text) = delta {
on_delta(&text);
}
}
Ok(())
}
/// 调用本机 Ollama 原生 `/api/chat` NDJSON 流式接口(自动保持模型热加载 `keep_alive: "15m"`)。
pub async fn stream_ollama<F>(
base_url: &str,
model: &str,
messages: &[ChatMessage],
cancel: &CancelToken,
on_delta: &mut F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
let base = if base_url.trim().is_empty() {
"http://127.0.0.1:11434"
} else {
base_url.trim().trim_end_matches('/').trim_end_matches("/v1")
};
let url = format!("{base}/api/chat");
let payload = json!({
"model": model,
"stream": true,
"keep_alive": "15m",
"options": {
"temperature": 0.2
},
"messages": messages,
});
let resp = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
r = http_client().post(&url).json(&payload).send() => {
r.map_err(|e| format!("连接本机 Ollama 失败 ({url}): {e}"))?
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let snippet: String = text.chars().take(300).collect();
return Err(format!("Ollama 返回状态 {status}: {snippet}"));
}
let mut stream = resp.bytes_stream();
let mut decoder = LineStreamDecoder::new_ollama_ndjson();
loop {
let next_chunk = tokio::select! {
biased;
_ = cancel.cancelled() => return Err("请求已取消".to_string()),
chunk = stream.next() => chunk,
};
let Some(chunk) = next_chunk else {
break;
};
let bytes = chunk.map_err(|e| format!("读取 Ollama 流失败: {e}"))?;
for delta in decoder.push(&bytes)? {
match delta {
StreamDelta::Content(text) => on_delta(&text),
StreamDelta::Done => return Ok(()),
}
}
}
for delta in decoder.finish()? {
if let StreamDelta::Content(text) = delta {
on_delta(&text);
}
}
Ok(())
}
+196
View File
@@ -0,0 +1,196 @@
//! `ttree-ai` —— 独立重写的 AI 翻译与对话模块。
//!
//! 核心特性:
//! 1. 与 GUI 框架完全解耦,通过回调流式输出增量文本(`on_delta`)并支持 `CancelToken` 毫秒级中断;
//! 2. 消除旧版重复发送两遍 Prompt 的缺陷,大幅降低首字延迟(TTFT);
//! 3. 支持 4 类模型后端:
//! - `OpenAiCompatible`:云端 OpenAI / DeepSeek / OpenRouter 兼容接口
//! - `Ollama`:本机 Ollama 服务(免 Key,支持 `/api/tags` 自动探测本地已装模型)
//! - `LocalServer`:本机 LM Studio / MLX Server / llama.cpp 服务(免 Key,支持 `/v1/models` 探测)
//! - `CandleGguf`:基于纯 Rust `candle` (Metal GPU 加速) 直接加载本地 `.gguf` 量化模型,常驻内存复用
pub mod gguf;
pub mod http_provider;
pub mod sse;
use ttree_core::language::language_display_name;
use ttree_core::{
AiBackendKind, AiTranslationConfig, CancelToken, ChatAiConfig, ChatMessage,
validate_language_pair, validate_messages,
};
pub use http_provider::discover_local_models;
/// 渲染精简高效的翻译 System Prompt(不再在 User 消息中重复拼接第二遍 Prompt)。
pub fn render_translate_prompt(template: &str, source: &str, target: &str) -> String {
let src_name = language_display_name(source);
let tgt_name = language_display_name(target);
template
.replace("{source}", src_name)
.replace("{target}", tgt_name)
}
/// 组装翻译消息:System 放指令,User 仅放待译原文(省去 50% 冗余 Prompt Token)。
pub fn build_translate_messages(
cfg: &AiTranslationConfig,
text: &str,
source: &str,
target: &str,
) -> Vec<ChatMessage> {
let prompt = render_translate_prompt(&cfg.translate_prompt, source, target);
vec![
ChatMessage::system(prompt),
ChatMessage::user(text.trim()),
]
}
/// 组装 AI 对话消息。
pub fn build_chat_messages(cfg: &ChatAiConfig, history: &[ChatMessage]) -> Vec<ChatMessage> {
let mut out = Vec::with_capacity(history.len() + 1);
if !cfg.chat_prompt.trim().is_empty() {
out.push(ChatMessage::system(cfg.chat_prompt.trim()));
}
out.extend_from_slice(history);
out
}
/// 执行流式 AI 翻译。
pub async fn stream_translate<F>(
cfg: &AiTranslationConfig,
text: &str,
source: &str,
target: &str,
cancel: &CancelToken,
mut on_delta: F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
if !cfg.enabled {
return Ok(());
}
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(());
}
validate_language_pair(source, target)?;
let messages = build_translate_messages(cfg, trimmed, source, target);
dispatch_stream(
cfg.backend,
&cfg.base_url,
&cfg.api_key,
&cfg.model,
&cfg.gguf_model_path,
&cfg.gguf_tokenizer_path,
&messages,
cancel,
&mut on_delta,
)
.await
}
/// 执行流式 AI 对话。
pub async fn stream_chat<F>(
cfg: &ChatAiConfig,
history: &[ChatMessage],
cancel: &CancelToken,
mut on_delta: F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
if history.is_empty() {
return Ok(());
}
validate_messages(history)?;
let messages = build_chat_messages(cfg, history);
dispatch_stream(
cfg.backend,
&cfg.base_url,
&cfg.api_key,
&cfg.model,
&cfg.gguf_model_path,
&cfg.gguf_tokenizer_path,
&messages,
cancel,
&mut on_delta,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_stream<F>(
backend: AiBackendKind,
base_url: &str,
api_key: &str,
model: &str,
gguf_model_path: &str,
gguf_tokenizer_path: &str,
messages: &[ChatMessage],
cancel: &CancelToken,
on_delta: &mut F,
) -> Result<(), String>
where
F: FnMut(&str) + Send,
{
match backend {
AiBackendKind::OpenAiCompatible => {
if api_key.trim().is_empty() {
return Err("未配置云端 API Key(可在设置中填入,或切换为本地 Ollama / LM Studio / GGUF 模型)".to_string());
}
http_provider::stream_openai_compatible(
base_url,
Some(api_key.trim()),
model,
messages,
cancel,
on_delta,
)
.await
}
AiBackendKind::Ollama => {
http_provider::stream_ollama(base_url, model, messages, cancel, on_delta).await
}
AiBackendKind::LocalServer => {
let opt_key = (!api_key.trim().is_empty()).then_some(api_key.trim());
http_provider::stream_openai_compatible(
base_url,
opt_key,
model,
messages,
cancel,
on_delta,
)
.await
}
AiBackendKind::CandleGguf => {
gguf::stream_gguf_inference(
gguf_model_path,
gguf_tokenizer_path,
messages,
cancel,
on_delta,
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_compact_translate_messages_without_duplicate_prompt() {
let cfg = AiTranslationConfig::default();
let msgs = build_translate_messages(&cfg, "Hello world", "en", "zh-CN");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
assert!(msgs[0].content.contains("简体中文"));
assert_eq!(msgs[1].role, "user");
// User 消息仅包含待译原文,不再重复拼接 system prompt
assert_eq!(msgs[1].content, "Hello world");
}
}
+182
View File
@@ -0,0 +1,182 @@
//! SSE (Server-Sent Events) 与 NDJSON (Ollama) 流式帧解码器。
use serde_json::Value;
pub const MAX_SSE_FRAME_BYTES: usize = 256 * 1024;
pub const MAX_STREAM_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamDelta {
Content(String),
Done,
}
pub fn parse_sse_line(line: &str) -> Result<Option<StreamDelta>, String> {
let Some(data) = line.strip_prefix("data:") else {
return Ok(None);
};
let data = data.trim();
if data.is_empty() {
return Ok(None);
}
if data == "[DONE]" {
return Ok(Some(StreamDelta::Done));
}
let v: Value =
serde_json::from_str(data).map_err(|e| format!("SSE JSON 解析失败: {e}"))?;
let content = v
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("delta"))
.and_then(|d| d.get("content"))
.and_then(Value::as_str);
Ok(content.map(|s| StreamDelta::Content(s.to_string())))
}
pub fn parse_ollama_ndjson_line(line: &str) -> Result<Option<StreamDelta>, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
let v: Value =
serde_json::from_str(trimmed).map_err(|e| format!("Ollama JSON 解析失败: {e}"))?;
if let Some(err) = v.get("error").and_then(Value::as_str) {
return Err(format!("Ollama 返回错误: {err}"));
}
let done = v.get("done").and_then(Value::as_bool).unwrap_or(false);
let content = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_str)
.unwrap_or("");
if !content.is_empty() {
return Ok(Some(StreamDelta::Content(content.to_string())));
}
if done {
return Ok(Some(StreamDelta::Done));
}
Ok(None)
}
pub struct LineStreamDecoder {
buffer: Vec<u8>,
frame_limit: usize,
output_limit: usize,
output_bytes: usize,
saw_done: bool,
ndjson_mode: bool,
}
impl LineStreamDecoder {
pub fn new_sse() -> Self {
Self {
buffer: Vec::new(),
frame_limit: MAX_SSE_FRAME_BYTES,
output_limit: MAX_STREAM_OUTPUT_BYTES,
output_bytes: 0,
saw_done: false,
ndjson_mode: false,
}
}
pub fn new_ollama_ndjson() -> Self {
Self {
buffer: Vec::new(),
frame_limit: MAX_SSE_FRAME_BYTES,
output_limit: MAX_STREAM_OUTPUT_BYTES,
output_bytes: 0,
saw_done: false,
ndjson_mode: true,
}
}
pub fn push(&mut self, bytes: &[u8]) -> Result<Vec<StreamDelta>, String> {
let mut deltas = Vec::new();
for &byte in bytes {
if byte == b'\n' {
if let Some(delta) = self.take_line()? {
let is_done = delta == StreamDelta::Done;
deltas.push(delta);
if is_done {
break;
}
}
continue;
}
if self.buffer.len() >= self.frame_limit {
return Err(format!("流式单帧超出 {} 字节上限", self.frame_limit));
}
self.buffer.push(byte);
}
Ok(deltas)
}
pub fn finish(mut self) -> Result<Vec<StreamDelta>, String> {
let mut deltas = Vec::new();
if !self.buffer.is_empty() {
if let Some(delta) = self.take_line()? {
deltas.push(delta);
}
}
if !self.saw_done && self.output_bytes == 0 {
return Err("AI 流提前结束且未返回有效内容".to_string());
}
Ok(deltas)
}
fn take_line(&mut self) -> Result<Option<StreamDelta>, String> {
let line_bytes = std::mem::take(&mut self.buffer);
let line = std::str::from_utf8(&line_bytes)
.map_err(|e| format!("流式响应包含非法 UTF-8: {e}"))?
.trim();
let parsed = if self.ndjson_mode {
parse_ollama_ndjson_line(line)?
} else {
parse_sse_line(line)?
};
let Some(delta) = parsed else {
return Ok(None);
};
match &delta {
StreamDelta::Content(c) => {
self.output_bytes = self
.output_bytes
.checked_add(c.len())
.ok_or_else(|| "流式输出大小溢出".to_string())?;
if self.output_bytes > self.output_limit {
return Err(format!("流式输出超出 {} 字节上限", self.output_limit));
}
}
StreamDelta::Done => {
self.saw_done = true;
}
}
Ok(Some(delta))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_sse_and_ollama_ndjson_streams() {
let mut sse = LineStreamDecoder::new_sse();
let out = sse
.push("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\ndata: [DONE]\n".as_bytes())
.unwrap();
assert_eq!(
out,
vec![StreamDelta::Content("你好".to_string()), StreamDelta::Done]
);
let mut ndjson = LineStreamDecoder::new_ollama_ndjson();
let out2 = ndjson
.push("{\"message\":{\"content\":\"世界\"},\"done\":false}\n{\"message\":{\"content\":\"\"},\"done\":true}\n".as_bytes())
.unwrap();
assert_eq!(
out2,
vec![StreamDelta::Content("世界".to_string()), StreamDelta::Done]
);
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "ttree-app"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "ttree"
path = "src/main.rs"
[dependencies]
ttree-core = { path = "../ttree-core" }
ttree-storage = { path = "../ttree-storage" }
ttree-platform-macos = { path = "../ttree-platform-macos" }
ttree-ocr = { path = "../ttree-ocr" }
ttree-translate = { path = "../ttree-translate" }
ttree-ai = { path = "../ttree-ai" }
anyhow.workspace = true
global-hotkey.workspace = true
tokio.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-core-foundation.workspace = true
objc2-foundation.workspace = true
objc2-quartz-core.workspace = true
+351
View File
@@ -0,0 +1,351 @@
//! `ttree` —— 100% 纯 Rust 原生 macOS AI 翻译与 OCR 桌面端入口。
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(target_os = "macos")]
mod state;
#[cfg(target_os = "macos")]
mod ui_helpers;
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("TTREE 纯 Rust 原生 AppKit 客户端仅支持 macOS 运行。");
}
#[cfg(target_os = "macos")]
fn main() {
macos_app::run();
}
#[cfg(target_os = "macos")]
mod macos_app {
use std::cell::RefCell;
use std::sync::Arc;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Bool, Sel};
use objc2::{define_class, msg_send, sel, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSBackingStoreType, NSColor, NSEvent,
NSEventModifierFlags, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSTextView,
NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState,
NSVisualEffectView, NSWindow, NSWindowStyleMask, NSWindowTitleVisibility,
};
use objc2_foundation::{
NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoop, NSRunLoopCommonModes, NSSize,
NSString, NSTimer,
};
use tokio::sync::mpsc::{self, UnboundedReceiver};
use ttree_core::{WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH};
use ttree_storage::{HistoryStore, SettingsStore};
use crate::state::{ActionTag, AppController, TabMode, UiEvent};
struct GlobalUiContext {
ctrl: AppController,
ui_rx: UnboundedReceiver<UiEvent>,
_status_item: Retained<NSStatusItem>,
}
thread_local! {
static UI_CTX: RefCell<Option<GlobalUiContext>> = const { RefCell::new(None) };
}
fn with_ctx<F: FnOnce(&mut GlobalUiContext)>(f: F) {
UI_CTX.with(|cell| {
if let Ok(mut borrow) = cell.try_borrow_mut() {
if let Some(ctx) = borrow.as_mut() {
f(ctx);
}
}
});
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "TTreeActionDispatcher"]
pub struct ActionDispatcher;
unsafe impl NSObjectProtocol for ActionDispatcher {}
impl ActionDispatcher {
#[unsafe(method(handleControl:))]
fn handle_control(&self, sender: &AnyObject) {
let raw_tag: isize = unsafe { msg_send![sender, tag] };
if let Some(tag) = ActionTag::from_raw(raw_tag) {
with_ctx(|ctx| ctx.ctrl.on_action(tag));
}
}
#[unsafe(method(handleTimer:))]
fn handle_timer(&self, _timer: &AnyObject) {
with_ctx(|ctx| {
while let Ok(hk_ev) = GlobalHotKeyEvent::receiver().try_recv() {
if hk_ev.state == HotKeyState::Pressed {
if let Some(action) = ctx.ctrl.parsed_shortcuts.action_for_id(hk_ev.id)
{
ctx.ctrl.handle_hotkey_action(action);
}
}
}
while let Ok(ui_ev) = ctx.ui_rx.try_recv() {
ctx.ctrl.handle_ui_event(ui_ev);
}
ctx.ctrl.poll_tick();
});
}
#[unsafe(method(textView:doCommandBySelector:))]
fn text_view_do_command(&self, _text_view: &NSTextView, command: Sel) -> Bool {
if command == sel!(cancelOperation:) {
with_ctx(|ctx| ctx.ctrl.on_action(ActionTag::HideOrEscape));
return Bool::YES;
}
if command == sel!(insertNewline:) {
let flags = NSEvent::modifierFlags_class();
if flags.contains(NSEventModifierFlags::Shift) {
return Bool::NO;
}
with_ctx(|ctx| match ctx.ctrl.active_tab {
TabMode::Translate => ctx.ctrl.start_translation(),
TabMode::Chat => ctx.ctrl.send_chat_from_input(),
TabMode::Settings => {}
});
return Bool::YES;
}
Bool::NO
}
#[unsafe(method(windowDidResignKey:))]
fn window_did_resign_key(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resign_key());
}
#[unsafe(method(windowDidResize:))]
fn window_did_resize(&self, _notification: &AnyObject) {
with_ctx(|ctx| ctx.ctrl.on_window_did_resize());
}
#[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))]
fn app_should_handle_reopen(&self, _app: &AnyObject, _has_visible: Bool) -> Bool {
with_ctx(|ctx| {
ctx.ctrl.switch_tab(TabMode::Translate);
ctx.ctrl.show_and_focus_window();
});
Bool::YES
}
}
);
impl ActionDispatcher {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
unsafe { msg_send![mtm.alloc::<Self>(), init] }
}
}
pub fn run() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("初始化 Tokio 异步运行时失败");
let mtm = MainThreadMarker::new().expect("必须在 macOS 主线程启动 AppKit UI");
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
let dispatcher = ActionDispatcher::new(mtm);
unsafe {
let _: () = msg_send![&*app, setDelegate: Some(&*dispatcher)];
}
install_standard_main_menu(mtm, &app);
let settings = Arc::new(SettingsStore::open_default());
let history = Arc::new(HistoryStore::open_default());
let hotkey_mgr = GlobalHotKeyManager::new().expect("初始化全局快捷键管理器失败");
let (win_w, win_h) = settings.window_size();
let appearance = settings.appearance();
let (window, effect_view) =
build_main_window(mtm, win_w, win_h, appearance.panel_opacity, &dispatcher);
let status_item = build_status_bar_item(mtm, &dispatcher);
let (ui_tx, ui_rx) = mpsc::unbounded_channel();
let mut ctrl = AppController::build(
mtm,
rt.handle().clone(),
ui_tx,
settings,
history,
hotkey_mgr,
window,
effect_view,
&dispatcher,
);
if std::env::args().any(|a| a == "--show") {
ctrl.show_and_focus_window();
}
UI_CTX.with(|cell| {
*cell.borrow_mut() = Some(GlobalUiContext {
ctrl,
ui_rx,
_status_item: status_item,
});
});
unsafe {
let timer = NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
0.025,
&dispatcher,
sel!(handleTimer:),
None,
true,
);
let run_loop = NSRunLoop::mainRunLoop();
run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes);
}
app.run();
}
fn build_main_window(
mtm: MainThreadMarker,
width: f64,
height: f64,
opacity: f64,
dispatcher: &ActionDispatcher,
) -> (Retained<NSWindow>, Retained<NSVisualEffectView>) {
unsafe {
let frame = NSRect::new(NSPoint::new(240.0, 240.0), NSSize::new(width, height));
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::FullSizeContentView;
let window = NSWindow::initWithContentRect_styleMask_backing_defer(
mtm.alloc(),
frame,
style,
NSBackingStoreType::Buffered,
false,
);
window.setTitle(&NSString::from_str("TTREE"));
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
window.setMovableByWindowBackground(true);
window.setReleasedWhenClosed(false);
window.setOpaque(false);
window.setBackgroundColor(Some(&NSColor::clearColor()));
window.setAlphaValue(opacity);
window.setMinSize(NSSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT));
let _: () = msg_send![&*window, setDelegate: Some(dispatcher)];
let effect_view = NSVisualEffectView::initWithFrame(mtm.alloc(), frame);
effect_view.setMaterial(NSVisualEffectMaterial::Popover);
effect_view.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow);
effect_view.setState(NSVisualEffectState::Active);
window.setContentView(Some(&effect_view));
(window, effect_view)
}
}
fn build_status_bar_item(
mtm: MainThreadMarker,
dispatcher: &ActionDispatcher,
) -> Retained<NSStatusItem> {
unsafe {
let status_bar = NSStatusBar::systemStatusBar();
let item = status_bar.statusItemWithLength(-1.0);
if let Some(btn) = item.button(mtm) {
btn.setTitle(&NSString::from_str("译"));
}
let menu = NSMenu::new(mtm);
let action_sel = sel!(handleControl:);
let add_item = |title: &str, tag: ActionTag, key: &str| {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action_sel),
&NSString::from_str(key),
);
mi.setTag(tag as isize);
mi.setTarget(Some(dispatcher));
menu.addItem(&mi);
};
add_item("翻译窗口", ActionTag::TabTranslate, "");
add_item("截图 OCR 翻译", ActionTag::TriggerOcr, "");
add_item("AI 对话", ActionTag::TabChat, "");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("偏好设置…", ActionTag::TabSettings, ",");
menu.addItem(&NSMenuItem::separatorItem(mtm));
add_item("退出 TTREE", ActionTag::TrayQuit, "q");
// 点击菜单项时如果窗口尚未显示则自动唤起(除了截图 OCR 与退出)
item.setMenu(Some(&menu));
item
}
}
/// 为 Accessory 模式应用安装标准 Edit 菜单,使 NSTextView / NSTextField 原生支持 Cmd+C / V / X / A / Z。
fn install_standard_main_menu(mtm: MainThreadMarker, app: &NSApplication) {
unsafe {
let main_menu = NSMenu::new(mtm);
// 1. App 子菜单
let app_item = NSMenuItem::new(mtm);
let app_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("TTREE"));
let close_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("隐藏窗口"),
Some(sel!(orderOut:)),
&NSString::from_str("w"),
);
let quit_item = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str("退出 TTREE"),
Some(sel!(terminate:)),
&NSString::from_str("q"),
);
app_menu.addItem(&close_item);
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
app_menu.addItem(&quit_item);
app_item.setSubmenu(Some(&app_menu));
main_menu.addItem(&app_item);
// 2. Edit 子菜单 (支持 Cmd+Z / Cmd+X / Cmd+C / Cmd+V / Cmd+A)
let edit_item = NSMenuItem::new(mtm);
let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("编辑"));
let edit_entries: [(&str, Sel, &str); 5] = [
("撤销", sel!(undo:), "z"),
("剪切", sel!(cut:), "x"),
("复制", sel!(copy:), "c"),
("粘贴", sel!(paste:), "v"),
("全选", sel!(selectAll:), "a"),
];
for (title, action, key) in edit_entries {
let mi = NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action),
&NSString::from_str(key),
);
edit_menu.addItem(&mi);
}
edit_item.setSubmenu(Some(&edit_menu));
main_menu.addItem(&edit_item);
app.setMainMenu(Some(&main_menu));
}
}
}
File diff suppressed because it is too large. Load diff
+302
View File
@@ -0,0 +1,302 @@
//! macOS 原生 AppKit (`objc2-app-kit`) 控件构建与读写辅助函数。
#![cfg(target_os = "macos")]
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Sel};
use objc2::{msg_send, MainThreadMarker};
use objc2_app_kit::{
NSBezelStyle, NSBox, NSBoxType, NSButton, NSButtonType, NSColor, NSControlStateValueOff,
NSControlStateValueOn, NSFont, NSPasteboard, NSPasteboardTypeString, NSPopUpButton,
NSScrollView, NSSecureTextField, NSSlider, NSTextField, NSTextView, NSView,
};
use objc2_core_foundation::{CGFloat, CGPoint, CGRect, CGSize};
use objc2_foundation::{NSPoint, NSRange, NSRect, NSSize, NSString};
/// 将以顶部为原点的 `(x, top_y, width, height)` 转换为 AppKit 默认左下角原点的 `NSRect`。
pub fn top_rect(parent_height: f64, x: f64, top_y: f64, width: f64, height: f64) -> NSRect {
let y = (parent_height - top_y - height).max(0.0);
NSRect::new(NSPoint::new(x, y), NSSize::new(width, height))
}
/// 创建静态文本标签 (`NSTextField` 设为只读无边框)。
pub fn make_label(
mtm: MainThreadMarker,
frame: NSRect,
text: &str,
font_size: f64,
bold: bool,
secondary: bool,
) -> Retained<NSTextField> {
let label = NSTextField::initWithFrame(mtm.alloc(), frame);
let ns_text = NSString::from_str(text);
label.setStringValue(&ns_text);
label.setBezeled(false);
label.setDrawsBackground(false);
label.setEditable(false);
label.setSelectable(false);
let font = if bold {
NSFont::boldSystemFontOfSize(font_size as CGFloat)
} else {
NSFont::systemFontOfSize(font_size as CGFloat)
};
label.setFont(Some(&font));
let color = if secondary {
NSColor::secondaryLabelColor()
} else {
NSColor::labelColor()
};
label.setTextColor(Some(&color));
label
}
/// 创建圆角操作按钮 (`NSButton`) 并绑定 `target` / `action` / `tag`。
pub fn make_button(
mtm: MainThreadMarker,
frame: NSRect,
title: &str,
tag: isize,
target: &AnyObject,
action: Sel,
) -> Retained<NSButton> {
unsafe {
let btn = NSButton::initWithFrame(mtm.alloc(), frame);
let ns_title = NSString::from_str(title);
btn.setTitle(&ns_title);
#[allow(deprecated)]
btn.setBezelStyle(NSBezelStyle::Rounded);
btn.setFont(Some(&NSFont::systemFontOfSize(12.5)));
btn.setTag(tag);
btn.setTarget(Some(target));
btn.setAction(Some(action));
btn
}
}
/// 创建复选框按钮 (`NSButtonType::Switch`)。
pub fn make_checkbox(
mtm: MainThreadMarker,
frame: NSRect,
title: &str,
checked: bool,
tag: isize,
target: &AnyObject,
action: Sel,
) -> Retained<NSButton> {
unsafe {
let btn = NSButton::initWithFrame(mtm.alloc(), frame);
btn.setButtonType(NSButtonType::Switch);
let ns_title = NSString::from_str(title);
btn.setTitle(&ns_title);
btn.setFont(Some(&NSFont::systemFontOfSize(12.5)));
btn.setState(if checked {
NSControlStateValueOn
} else {
NSControlStateValueOff
});
btn.setTag(tag);
btn.setTarget(Some(target));
btn.setAction(Some(action));
btn
}
}
/// 创建下拉选择框 (`NSPopUpButton`)。
pub fn make_popup(
mtm: MainThreadMarker,
frame: NSRect,
items: &[&str],
selected_idx: usize,
tag: isize,
target: &AnyObject,
action: Sel,
) -> Retained<NSPopUpButton> {
unsafe {
let popup = NSPopUpButton::initWithFrame_pullsDown(mtm.alloc(), frame, false);
popup.setFont(Some(&NSFont::systemFontOfSize(12.5)));
for item in items {
let s = NSString::from_str(item);
popup.addItemWithTitle(&s);
}
if selected_idx < items.len() {
popup.selectItemAtIndex(selected_idx as isize);
}
popup.setTag(tag);
popup.setTarget(Some(target));
popup.setAction(Some(action));
popup
}
}
/// 创建单行可编辑输入框 (`NSTextField`)。
pub fn make_text_field(
mtm: MainThreadMarker,
frame: NSRect,
value: &str,
placeholder: &str,
) -> Retained<NSTextField> {
let field = NSTextField::initWithFrame(mtm.alloc(), frame);
let ns_val = NSString::from_str(value);
field.setStringValue(&ns_val);
let ns_ph = NSString::from_str(placeholder);
field.setPlaceholderString(Some(&ns_ph));
field.setEditable(true);
field.setSelectable(true);
field.setBezeled(true);
field.setDrawsBackground(true);
field.setFont(Some(&NSFont::systemFontOfSize(12.5)));
field
}
/// 创建单行密码/密钥输入框 (`NSSecureTextField`)。
pub fn make_secure_field(
mtm: MainThreadMarker,
frame: NSRect,
placeholder: &str,
) -> Retained<NSSecureTextField> {
let field = NSSecureTextField::initWithFrame(mtm.alloc(), frame);
let ns_ph = NSString::from_str(placeholder);
field.setPlaceholderString(Some(&ns_ph));
field.setEditable(true);
field.setSelectable(true);
field.setBezeled(true);
field.setDrawsBackground(true);
field.setFont(Some(&NSFont::systemFontOfSize(12.5)));
field
}
/// 创建滑块控件 (`NSSlider`)。
pub fn make_slider(
mtm: MainThreadMarker,
frame: NSRect,
min: f64,
max: f64,
value: f64,
tag: isize,
target: &AnyObject,
action: Sel,
) -> Retained<NSSlider> {
unsafe {
let slider = NSSlider::initWithFrame(mtm.alloc(), frame);
slider.setMinValue(min);
slider.setMaxValue(max);
slider.setDoubleValue(value);
slider.setContinuous(true);
slider.setTag(tag);
slider.setTarget(Some(target));
slider.setAction(Some(action));
slider
}
}
/// 创建圆角卡片容器 (`NSBox`)。
pub fn make_card_box(mtm: MainThreadMarker, frame: NSRect) -> Retained<NSBox> {
let card = NSBox::initWithFrame(mtm.alloc(), frame);
card.setBoxType(NSBoxType::Custom);
card.setCornerRadius(10.0);
card.setBorderWidth(1.0);
card.setBorderColor(&NSColor::separatorColor());
card.setFillColor(&NSColor::controlBackgroundColor().colorWithAlphaComponent(0.55));
let zero_margins = CGSize::new(0.0, 0.0);
card.setContentViewMargins(zero_margins);
card
}
/// 创建带垂直滚动条的多行 `NSTextView`。
pub fn make_scrollable_text_view(
mtm: MainThreadMarker,
frame: NSRect,
editable: bool,
font_size: f64,
delegate: Option<&AnyObject>,
) -> (Retained<NSScrollView>, Retained<NSTextView>) {
unsafe {
let scroll = NSScrollView::initWithFrame(mtm.alloc(), frame);
scroll.setHasVerticalScroller(true);
scroll.setHasHorizontalScroller(false);
scroll.setDrawsBackground(editable);
if editable {
scroll.setBackgroundColor(&NSColor::textBackgroundColor().colorWithAlphaComponent(0.65));
}
let content_size = scroll.contentSize();
let text_frame = CGRect::new(CGPoint::new(0.0, 0.0), content_size);
let text_view = NSTextView::initWithFrame(mtm.alloc(), text_frame);
text_view.setEditable(editable);
text_view.setSelectable(true);
text_view.setRichText(false);
text_view.setDrawsBackground(false);
text_view.setFont(Some(&NSFont::systemFontOfSize(font_size as CGFloat)));
text_view.setTextColor(Some(&NSColor::labelColor()));
text_view.setVerticallyResizable(true);
text_view.setHorizontallyResizable(false);
let inset = NSSize::new(8.0, 8.0);
text_view.setTextContainerInset(inset);
if let Some(container) = text_view.textContainer() {
container.setWidthTracksTextView(true);
container.setContainerSize(NSSize::new(content_size.width, 10_000_000.0));
}
if editable {
let _: () = msg_send![&*text_view, setAutomaticQuoteSubstitutionEnabled: false];
let _: () = msg_send![&*text_view, setAutomaticDashSubstitutionEnabled: false];
let _: () = msg_send![&*text_view, setAutomaticSpellingCorrectionEnabled: false];
}
if let Some(del) = delegate {
let _: () = msg_send![&*text_view, setDelegate: Some(del)];
}
scroll.setDocumentView(Some(&text_view));
(scroll, text_view)
}
}
/// 读取 `NSTextView` 内的全部纯文本。
pub fn get_text_view_string(tv: &NSTextView) -> String {
tv.string().to_string()
}
/// 设置 `NSTextView` 的纯文本内容。
pub fn set_text_view_string(tv: &NSTextView, text: &str) {
let s = NSString::from_str(text);
tv.setString(&s);
}
/// 向 `NSTextView` 末尾追加流式增量文本并自动滚动到底部。
pub fn append_text_view_string(tv: &NSTextView, delta: &str, scroll_to_end: bool) {
let mut current = tv.string().to_string();
current.push_str(delta);
let s = NSString::from_str(&current);
tv.setString(&s);
if scroll_to_end {
let len = s.length();
tv.scrollRangeToVisible(NSRange::new(len, 0));
}
}
/// 读取 `NSTextField` 或 `NSSecureTextField` 文本。
pub fn get_field_string(field: &NSTextField) -> String {
field.stringValue().to_string()
}
/// 设置 `NSTextField` 文本。
pub fn set_field_string(field: &NSTextField, value: &str) {
let s = NSString::from_str(value);
field.setStringValue(&s);
}
/// 将字符串写入 macOS 系统剪贴板。
pub fn copy_to_pasteboard(text: &str) -> bool {
if text.trim().is_empty() {
return false;
}
let pb = NSPasteboard::generalPasteboard();
pb.clearContents();
let ns_str = NSString::from_str(text);
let pb_type = unsafe { NSPasteboardTypeString };
pb.setString_forType(&ns_str, pb_type)
}
/// 将子视图添加到父视图。
pub fn add_subview(parent: &NSView, child: &NSView) {
parent.addSubview(child);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "ttree-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
url.workspace = true
+119
View File
@@ -0,0 +1,119 @@
//! 轻量任务取消令牌(`CancelToken`)与单槽抢占器(`CancelSlot`)。
//!
//! 解决痛点:当用户快速修改输入或连续触发划词/OCR 时,立即取消正在进行的旧请求
//! (尤其是耗时较长的 AI 流式生成),绝不让上一条慢请求排队阻塞新翻译。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::Notify;
#[derive(Default)]
struct Inner {
cancelled: AtomicBool,
notify: Notify,
}
/// 可克隆的异步取消令牌。
#[derive(Clone, Default)]
pub struct CancelToken {
request_id: u64,
inner: Arc<Inner>,
}
impl CancelToken {
pub fn new(request_id: u64) -> Self {
Self {
request_id,
inner: Arc::new(Inner::default()),
}
}
/// 当前令牌对应的单调递增请求 ID。
pub fn request_id(&self) -> u64 {
self.request_id
}
/// 触发取消并唤醒所有正在等待的异步任务。
pub fn cancel(&self) {
if !self.inner.cancelled.swap(true, Ordering::SeqCst) {
self.inner.notify.notify_waiters();
}
}
/// 是否已被取消。
pub fn is_cancelled(&self) -> bool {
self.inner.cancelled.load(Ordering::SeqCst)
}
/// 异步等待直至令牌被取消(配合 `tokio::select!` 中断网络流)。
pub async fn cancelled(&self) {
if self.is_cancelled() {
return;
}
let notified = self.inner.notify.notified();
if self.is_cancelled() {
return;
}
notified.await;
}
}
/// 单槽抢占管理器:每次签发新 `CancelToken` 时自动取消上一个未完成的令牌。
#[derive(Default)]
pub struct CancelSlot {
state: Mutex<(u64, Option<CancelToken>)>,
}
impl CancelSlot {
pub const fn new() -> Self {
Self {
state: Mutex::new((0, None)),
}
}
/// 取消旧令牌并签发新一代 `CancelToken`。
pub fn next(&self) -> CancelToken {
let mut guard = self.state.lock().expect("CancelSlot lock poisoned");
if let Some(prev) = guard.1.take() {
prev.cancel();
}
guard.0 = guard.0.wrapping_add(1);
let token = CancelToken::new(guard.0);
guard.1 = Some(token.clone());
token
}
/// 显式取消当前槽位中的活跃任务。
pub fn cancel_current(&self) {
let mut guard = self.state.lock().expect("CancelSlot lock poisoned");
if let Some(prev) = guard.1.take() {
prev.cancel();
}
}
/// 当前最新请求 ID。
pub fn current_id(&self) -> u64 {
self.state.lock().map(|g| g.0).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issuing_next_token_cancels_previous_token() {
let slot = CancelSlot::new();
let first = slot.next();
assert_eq!(first.request_id(), 1);
assert!(!first.is_cancelled());
let second = slot.next();
assert_eq!(second.request_id(), 2);
assert!(first.is_cancelled());
assert!(!second.is_cancelled());
// cancelled() future on first resolves immediately
first.cancelled().await;
}
}
+109
View File
@@ -0,0 +1,109 @@
//! 对话消息与会话上下文裁剪。
use serde::{Deserialize, Serialize};
const ALLOWED_ROLES: &[&str] = &["user", "assistant", "system"];
pub const MAX_CONTENT_BYTES: usize = 64 * 1024;
pub const MAX_MESSAGES: usize = 200;
pub const MAX_CONTEXT_MESSAGES: usize = 24;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),
content: content.into(),
}
}
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: content.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Conversation {
pub id: usize,
pub messages: Vec<ChatMessage>,
pub summary: String,
}
/// 校验消息角色白名单、单条长度与总条数。
pub fn validate_messages(msgs: &[ChatMessage]) -> Result<(), String> {
if msgs.len() > MAX_MESSAGES {
return Err(format!("消息数超出上限 {MAX_MESSAGES} 条"));
}
for m in msgs {
if !ALLOWED_ROLES.contains(&m.role.as_str()) {
return Err(format!("非法消息角色: {}", m.role));
}
if m.content.len() > MAX_CONTENT_BYTES {
return Err(format!("单条消息超出 {MAX_CONTENT_BYTES} 字节上限"));
}
}
Ok(())
}
/// 截取最近 24 条非空消息,并丢弃前导孤立的 `assistant` 消息,确保上下文以 `user` 开头。
pub fn recent_chat_context(messages: &[ChatMessage]) -> Vec<ChatMessage> {
let filtered: Vec<ChatMessage> = messages
.iter()
.filter(|m| !m.content.trim().is_empty())
.cloned()
.collect();
let start = filtered.len().saturating_sub(MAX_CONTEXT_MESSAGES);
let mut slice = &filtered[start..];
while let Some(first) = slice.first() {
if first.role == "assistant" {
slice = &slice[1..];
} else {
break;
}
}
slice.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_context_and_drops_leading_orphan_assistant() {
let mut msgs = Vec::new();
for i in 0..30 {
let role = if i % 2 == 0 { "user" } else { "assistant" };
msgs.push(ChatMessage {
role: role.to_string(),
content: format!("msg-{i}"),
});
}
let ctx = recent_chat_context(&msgs);
assert!(ctx.len() <= MAX_CONTEXT_MESSAGES);
assert_eq!(ctx.first().unwrap().role, "user");
}
#[test]
fn validates_roles_and_lengths() {
assert!(validate_messages(&[ChatMessage::user("hi")]).is_ok());
assert!(validate_messages(&[ChatMessage {
role: "tool".to_string(),
content: "x".to_string()
}])
.is_err());
}
}
+306
View File
@@ -0,0 +1,306 @@
//! 核心配置数据结构与校验规则。
use serde::{Deserialize, Serialize};
pub const MAX_BASE_URL_BYTES: usize = 2 * 1024;
pub const MAX_MODEL_BYTES: usize = 256;
pub const MAX_PATH_BYTES: usize = 4 * 1024;
pub const MAX_PROMPT_BYTES: usize = 64 * 1024;
pub const MAX_API_KEY_BYTES: usize = 16 * 1024;
pub const PANEL_OPACITY_MIN: f64 = 0.35;
pub const PANEL_OPACITY_MAX: f64 = 1.0;
pub const WINDOW_MIN_WIDTH: f64 = 520.0;
pub const WINDOW_MIN_HEIGHT: f64 = 340.0;
/// AI 引擎后端类型:支持云端 OpenAI 兼容、本机 Ollama/LM Studio/MLX 服务、以及直接加载 GGUF。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AiBackendKind {
/// 云端 OpenAI 兼容接口(OpenAI / DeepSeek / OpenRouter 等,需 Keychain API Key)
#[default]
OpenAiCompatible,
/// 本机 Ollama 服务(默认 `http://127.0.0.1:11434`,免 API Key,支持 `/api/tags` 枚举模型)
Ollama,
/// 本机 LM Studio / MLX Server / llama.cpp 服务(默认 `http://127.0.0.1:1234/v1`,免或可选 Key)
LocalServer,
/// 纯 Rust Candle 框架直接加载本地 `.gguf` 量化模型文件(Metal GPU 加速)
CandleGguf,
}
impl AiBackendKind {
pub const ALL: [AiBackendKind; 4] = [
AiBackendKind::OpenAiCompatible,
AiBackendKind::Ollama,
AiBackendKind::LocalServer,
AiBackendKind::CandleGguf,
];
pub fn label(self) -> &'static str {
match self {
Self::OpenAiCompatible => "云端 API (OpenAI 兼容)",
Self::Ollama => "本地服务: Ollama",
Self::LocalServer => "本地服务: LM Studio / MLX",
Self::CandleGguf => "本地文件: 直接加载 .gguf (Candle Metal)",
}
}
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "https://api.openai.com/v1",
Self::Ollama => "http://127.0.0.1:11434",
Self::LocalServer => "http://127.0.0.1:1234/v1",
Self::CandleGguf => "",
}
}
pub fn requires_api_key(self) -> bool {
matches!(self.into(), Self::OpenAiCompatible)
}
}
fn serialize_empty_api_key<S>(_value: &String, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str("")
}
fn default_true() -> bool {
true
}
/// AI 翻译独立配置(支持开关、切换云端/Ollama/LM Studio/MLX/GGUF)。
#[derive(Clone, Serialize, Deserialize)]
pub struct AiTranslationConfig {
/// 是否在翻译面板右侧启用 AI 翻译对照列。
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub backend: AiBackendKind,
pub base_url: String,
#[serde(default, serialize_with = "serialize_empty_api_key")]
pub api_key: String,
#[serde(default)]
pub has_api_key: bool,
#[serde(default)]
pub clear_api_key: bool,
pub model: String,
/// 当 `backend == CandleGguf` 时使用的 `.gguf` 权重文件路径。
#[serde(default)]
pub gguf_model_path: String,
/// 当 `backend == CandleGguf` 时使用的 `tokenizer.json` 路径(留空则自动在同目录查找)。
#[serde(default)]
pub gguf_tokenizer_path: String,
pub translate_prompt: String,
}
impl std::fmt::Debug for AiTranslationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AiTranslationConfig")
.field("enabled", &self.enabled)
.field("backend", &self.backend)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("model", &self.model)
.field("gguf_model_path", &self.gguf_model_path)
.finish_non_exhaustive()
}
}
impl Default for AiTranslationConfig {
fn default() -> Self {
Self {
enabled: true,
backend: AiBackendKind::OpenAiCompatible,
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
has_api_key: false,
clear_api_key: false,
model: "gpt-4o-mini".to_string(),
gguf_model_path: String::new(),
gguf_tokenizer_path: String::new(),
translate_prompt: "你是一个专业、地道的翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。".to_string(),
}
}
}
/// AI 对话配置(与翻译独立)。
#[derive(Clone, Serialize, Deserialize)]
pub struct ChatAiConfig {
#[serde(default)]
pub backend: AiBackendKind,
pub base_url: String,
#[serde(default, serialize_with = "serialize_empty_api_key")]
pub api_key: String,
#[serde(default)]
pub has_api_key: bool,
#[serde(default)]
pub clear_api_key: bool,
pub model: String,
#[serde(default)]
pub gguf_model_path: String,
#[serde(default)]
pub gguf_tokenizer_path: String,
#[serde(default)]
pub chat_prompt: String,
}
impl std::fmt::Debug for ChatAiConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatAiConfig")
.field("backend", &self.backend)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("model", &self.model)
.field("gguf_model_path", &self.gguf_model_path)
.finish_non_exhaustive()
}
}
impl Default for ChatAiConfig {
fn default() -> Self {
Self {
backend: AiBackendKind::OpenAiCompatible,
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
has_api_key: false,
clear_api_key: false,
model: "gpt-4o-mini".to_string(),
gguf_model_path: String::new(),
gguf_tokenizer_path: String::new(),
chat_prompt: String::new(),
}
}
}
/// 全局快捷键配置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShortcutConfig {
pub toggle: String,
pub ocr: String,
#[serde(default)]
pub ai_dialog: String,
#[serde(default)]
pub selection_translate: String,
#[serde(default)]
pub selection_ai_dialog: String,
}
impl Default for ShortcutConfig {
fn default() -> Self {
Self {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: String::new(),
selection_translate: String::new(),
selection_ai_dialog: String::new(),
}
}
}
/// 外观配置。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AppearanceConfig {
#[serde(default = "default_panel_opacity")]
pub panel_opacity: f64,
}
fn default_panel_opacity() -> f64 {
0.96
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
panel_opacity: default_panel_opacity(),
}
}
}
impl AppearanceConfig {
pub fn clamp_opacity(value: f64) -> f64 {
if value.is_nan() {
return default_panel_opacity();
}
value.clamp(PANEL_OPACITY_MIN, PANEL_OPACITY_MAX)
}
}
pub fn validate_field_len(label: &str, value: &str, max_bytes: usize) -> Result<(), String> {
if value.len() > max_bytes {
return Err(format!("{label}超出 {max_bytes} 字节上限"));
}
Ok(())
}
/// 校验并规范化接口地址:
/// - 远程地址要求 HTTPS
/// - 本机回环地址(`localhost` / `127.0.0.1` / `::1`)或局域网本地模型服务允许 HTTP
/// - 拒绝包含用户名/密码(userinfo)或非 HTTP(S) 协议
pub fn normalize_base_url(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("base_url 不能为空".to_string());
}
validate_field_len("base_url", trimmed, MAX_BASE_URL_BYTES)?;
let url = url::Url::parse(trimmed).map_err(|e| format!("base_url 非法: {e}"))?;
if !url.username().is_empty() || url.password().is_some() {
return Err("base_url 不允许包含内嵌用户名或密码".to_string());
}
match url.scheme() {
"https" => {}
"http" if is_loopback_url(&url) => {}
"http" => return Err("远程 base_url 必须使用 HTTPS;HTTP 仅允许本机回环地址".to_string()),
other => return Err(format!("base_url 必须为 HTTP(S),不支持 {other}")),
}
if url.host().is_none() {
return Err("base_url 必须包含主机名或 IP 地址".to_string());
}
let out = trimmed.trim_end_matches('/');
Ok(if out.is_empty() {
trimmed.to_string()
} else {
out.to_string()
})
}
fn is_loopback_url(url: &url::Url) -> bool {
match url.host() {
Some(url::Host::Domain(host)) => {
host.trim_end_matches('.').eq_ignore_ascii_case("localhost")
}
Some(url::Host::Ipv4(host)) => host.is_loopback(),
Some(url::Host::Ipv6(host)) => host.is_loopback(),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_https_and_loopback_http_urls() {
assert_eq!(
normalize_base_url("https://api.openai.com/v1/").unwrap(),
"https://api.openai.com/v1"
);
assert_eq!(
normalize_base_url("http://127.0.0.1:11434/").unwrap(),
"http://127.0.0.1:11434"
);
assert_eq!(
normalize_base_url("http://localhost:1234/v1").unwrap(),
"http://localhost:1234/v1"
);
}
#[test]
fn rejects_remote_http_and_file_urls() {
assert!(normalize_base_url("http://example.com/v1").is_err());
assert!(normalize_base_url("file:///etc/passwd").is_err());
assert!(normalize_base_url("https://user:pass@example.com/v1").is_err());
}
}
+195
View File
@@ -0,0 +1,195 @@
//! 语言列表与自适应目标方向判定(中 ⇄ 英)。
pub const ADAPTIVE_TARGET: &str = "adaptive";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LanguageOption {
pub code: &'static str,
pub label: &'static str,
}
pub const SOURCE_LANGUAGES: &[LanguageOption] = &[
LanguageOption {
code: "auto",
label: "自动检测",
},
LanguageOption {
code: "zh-CN",
label: "简体中文",
},
LanguageOption {
code: "zh-TW",
label: "繁体中文",
},
LanguageOption {
code: "en",
label: "英语",
},
LanguageOption {
code: "ja",
label: "日语",
},
LanguageOption {
code: "ko",
label: "韩语",
},
LanguageOption {
code: "fr",
label: "法语",
},
LanguageOption {
code: "de",
label: "德语",
},
LanguageOption {
code: "es",
label: "西班牙语",
},
LanguageOption {
code: "ru",
label: "俄语",
},
];
pub const TARGET_LANGUAGES: &[LanguageOption] = &[
LanguageOption {
code: ADAPTIVE_TARGET,
label: "中 ⇄ 英(自适应)",
},
LanguageOption {
code: "zh-CN",
label: "简体中文",
},
LanguageOption {
code: "zh-TW",
label: "繁体中文",
},
LanguageOption {
code: "en",
label: "英语",
},
LanguageOption {
code: "ja",
label: "日语",
},
LanguageOption {
code: "ko",
label: "韩语",
},
LanguageOption {
code: "fr",
label: "法语",
},
LanguageOption {
code: "de",
label: "德语",
},
LanguageOption {
code: "es",
label: "西班牙语",
},
LanguageOption {
code: "ru",
label: "俄语",
},
];
const SUPPORTED_SOURCE_CODES: &[&str] = &[
"auto", "zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru",
];
const SUPPORTED_TARGET_CODES: &[&str] =
&["zh-CN", "zh-TW", "en", "ja", "ko", "fr", "de", "es", "ru"];
/// 判断字符是否属于 CJK 统一表意文字(含扩展区)。
fn is_han_char(ch: char) -> bool {
matches!(
ch as u32,
0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xF900..=0xFAFF
| 0x20000..=0x2A6DF
| 0x2A700..=0x2B73F
| 0x2B740..=0x2B81F
| 0x2B820..=0x2CEAF
| 0x2CEB0..=0x2EBEF
| 0x30000..=0x3134F
)
}
/// 根据输入文本自动选择目标语言:包含汉字则译为英语 (`en`),否则译为简体中文 (`zh-CN`)。
pub fn resolve_adaptive_target(text: &str) -> &'static str {
if text.chars().any(is_han_char) {
"en"
} else {
"zh-CN"
}
}
/// 将用户选择的 `(source, target)`(可能含 `adaptive`)解析为实际请求的语言对。
pub fn resolve_language_pair(source: &str, target: &str, text: &str) -> (String, String) {
if target == ADAPTIVE_TARGET {
("auto".to_string(), resolve_adaptive_target(text).to_string())
} else {
(source.to_string(), target.to_string())
}
}
/// 校验实际发送给引擎的语言对是否合法。
pub fn validate_language_pair(source: &str, target: &str) -> Result<(), String> {
if !SUPPORTED_SOURCE_CODES.contains(&source) {
return Err(format!("不支持的源语言代码: {source}"));
}
if !SUPPORTED_TARGET_CODES.contains(&target) {
return Err(format!("不支持的目标语言代码: {target}"));
}
Ok(())
}
/// 根据目标语言代码返回中文可读名称(供 AI 提示词渲染使用)。
pub fn language_display_name(code: &str) -> &str {
match code {
"auto" => "自动检测的源语言",
"zh-CN" => "简体中文",
"zh-TW" => "繁体中文",
"en" => "英语 (English)",
"ja" => "日语 (Japanese)",
"ko" => "韩语 (Korean)",
"fr" => "法语 (French)",
"de" => "德语 (German)",
"es" => "西班牙语 (Spanish)",
"ru" => "俄语 (Russian)",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adaptive_target_detects_chinese_and_non_chinese() {
assert_eq!(resolve_adaptive_target("你好,世界"), "en");
assert_eq!(resolve_adaptive_target("Hello, world!"), "zh-CN");
assert_eq!(resolve_adaptive_target("Rust 性能优化"), "en");
assert_eq!(resolve_adaptive_target("12345 !@#"), "zh-CN");
}
#[test]
fn resolves_adaptive_pair_to_concrete_codes() {
let (src, tgt) = resolve_language_pair("en", ADAPTIVE_TARGET, "今天天气不错");
assert_eq!(src, "auto");
assert_eq!(tgt, "en");
let (src, tgt) = resolve_language_pair("zh-CN", "ja", "今天天气不错");
assert_eq!(src, "zh-CN");
assert_eq!(tgt, "ja");
}
#[test]
fn validates_known_language_codes() {
assert!(validate_language_pair("auto", "zh-CN").is_ok());
assert!(validate_language_pair("en", "zh-TW").is_ok());
assert!(validate_language_pair("en", "auto").is_err());
assert!(validate_language_pair("unknown", "en").is_err());
}
}
+17
View File
@@ -0,0 +1,17 @@
//! `ttree-core` —— 核心领域模型、语言自适应解析、取消令牌、配置模型与对话上下文处理。
pub mod cancel;
pub mod chat;
pub mod config;
pub mod language;
pub use cancel::{CancelSlot, CancelToken};
pub use chat::{recent_chat_context, validate_messages, ChatMessage, Conversation};
pub use config::{
normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig,
ShortcutConfig, PANEL_OPACITY_MAX, PANEL_OPACITY_MIN, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
};
pub use language::{
resolve_adaptive_target, resolve_language_pair, validate_language_pair, LanguageOption,
ADAPTIVE_TARGET, SOURCE_LANGUAGES, TARGET_LANGUAGES,
};
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ttree-ocr"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
tempfile.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2.workspace = true
objc2-core-foundation.workspace = true
objc2-core-graphics.workspace = true
objc2-core-image.workspace = true
objc2-core-ml.workspace = true
objc2-foundation.workspace = true
objc2-image-io.workspace = true
objc2-vision.workspace = true
+174
View File
@@ -0,0 +1,174 @@
//! 图像预处理:解码 PNG 并以截图边缘自适应背景色填充边距,生成 8-bit sRGB `CGImage`。
//!
//! 关键改进:
//! 旧版在 <128px 的小截图四周填充纯白 `(1.0, 1.0, 1.0)`,导致深色模式(白字黑底)单行截图
//! 产生刺眼的白框伪影,干扰 Vision 行检测。新版自动采样原图四周边缘像素的平均色进行无缝填充。
#![cfg(target_os = "macos")]
use objc2_core_foundation::{CFData, CFRetained, CGPoint, CGRect, CGSize};
use objc2_core_graphics::{
kCGColorSpaceSRGB, CGBitmapContextCreate, CGBitmapContextCreateImage, CGColorSpace, CGContext,
CGImage, CGImageAlphaInfo, CGImageByteOrderInfo,
};
use objc2_image_io::CGImageSource;
const MIN_DIMENSION: usize = 128;
const BORDER: usize = 16;
const MAX_PIXELS: usize = 32_000_000;
pub fn prepare_png(bytes: &[u8]) -> Result<CFRetained<CGImage>, String> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 24 {
return Err("截图不是有效的 PNG 图片,请重新框选。".to_string());
}
if &bytes[12..16] != b"IHDR" {
return Err("截图 PNG 缺少图片尺寸信息,请重新框选。".to_string());
}
let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize;
let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize;
canvas_size(width, height)?;
let data = CFData::from_bytes(bytes);
let source = unsafe { CGImageSource::with_data(&data, None) }
.ok_or_else(|| "系统无法读取截图 PNG,请重新框选。".to_string())?;
let image = unsafe { source.image_at_index(0, None) }
.ok_or_else(|| "截图 PNG 解码失败,请重新框选。".to_string())?;
let width = CGImage::width(Some(&image));
let height = CGImage::height(Some(&image));
let (canvas_width, canvas_height) = canvas_size(width, height)?;
let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
.ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?;
let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGImageByteOrderInfo::Order32Big.0;
// 先将原图解码到一块小缓冲区以采样四周边缘背景色,确保深色/浅色截图都能平滑延展背景
let (bg_r, bg_g, bg_b) = sample_border_rgb(&image, width, height, &color_space, bitmap_info);
let context = unsafe {
CGBitmapContextCreate(
std::ptr::null_mut(),
canvas_width,
canvas_height,
8,
canvas_width * 4,
Some(&color_space),
bitmap_info,
)
}
.ok_or_else(|| "创建 OCR 位图失败".to_string())?;
CGContext::set_rgb_fill_color(Some(&context), bg_r, bg_g, bg_b, 1.0);
CGContext::fill_rect(Some(&context), rect(0, 0, canvas_width, canvas_height));
CGContext::draw_image(
Some(&context),
rect(
(canvas_width - width) / 2,
(canvas_height - height) / 2,
width,
height,
),
Some(&image),
);
CGBitmapContextCreateImage(Some(&context)).ok_or_else(|| "生成 OCR 位图失败".to_string())
}
/// 采样原图外圈像素的平均 RGB 值(0.0..=1.0),失败时回退白色。
fn sample_border_rgb(
image: &CGImage,
width: usize,
height: usize,
color_space: &CGColorSpace,
bitmap_info: u32,
) -> (f64, f64, f64) {
let mut buf = vec![255u8; width * height * 4];
let Some(ctx) = (unsafe {
CGBitmapContextCreate(
buf.as_mut_ptr().cast(),
width,
height,
8,
width * 4,
Some(color_space),
bitmap_info,
)
}) else {
return (1.0, 1.0, 1.0);
};
CGContext::set_rgb_fill_color(Some(&ctx), 1.0, 1.0, 1.0, 1.0);
CGContext::fill_rect(Some(&ctx), rect(0, 0, width, height));
CGContext::draw_image(Some(&ctx), rect(0, 0, width, height), Some(image));
let mut r_sum: u64 = 0;
let mut g_sum: u64 = 0;
let mut b_sum: u64 = 0;
let mut count: u64 = 0;
let mut add_pixel = |x: usize, y: usize| {
let idx = (y * width + x) * 4;
r_sum += buf[idx] as u64;
g_sum += buf[idx + 1] as u64;
b_sum += buf[idx + 2] as u64;
count += 1;
};
for x in 0..width {
add_pixel(x, 0);
if height > 1 {
add_pixel(x, height - 1);
}
}
if height > 2 {
for y in 1..(height - 1) {
add_pixel(0, y);
if width > 1 {
add_pixel(width - 1, y);
}
}
}
if count == 0 {
return (1.0, 1.0, 1.0);
}
(
(r_sum as f64 / count as f64) / 255.0,
(g_sum as f64 / count as f64) / 255.0,
(b_sum as f64 / count as f64) / 255.0,
)
}
pub fn canvas_size(width: usize, height: usize) -> Result<(usize, usize), String> {
if width == 0 || height == 0 {
return Err("截图尺寸为空,请重新框选。".to_string());
}
let padded = width
.checked_add(BORDER * 2)
.zip(height.checked_add(BORDER * 2))
.map(|(w, h)| (w.max(MIN_DIMENSION), h.max(MIN_DIMENSION)))
.filter(|(w, h)| w.checked_mul(*h).is_some_and(|pixels| pixels <= MAX_PIXELS));
padded.ok_or_else(|| "截图区域过大,请缩小框选范围后重试。".to_string())
}
fn rect(x: usize, y: usize, width: usize, height: usize) -> CGRect {
CGRect {
origin: CGPoint {
x: x as _,
y: y as _,
},
size: CGSize {
width: width as _,
height: height as _,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pads_small_captures_to_minimum_dimension() {
assert_eq!(canvas_size(320, 24).unwrap(), (352, 128));
assert_eq!(canvas_size(8, 8).unwrap(), (128, 128));
assert!(canvas_size(0, 10).is_err());
}
}
+76
View File
@@ -0,0 +1,76 @@
//! `ttree-ocr` —— macOS 原生截图与 Apple Vision OCR 引擎。
//!
//! 针对旧版“莫名其妙无法使用”的 4 项核心根治:
//! 1. 强类型 `OcrOutcome` 区分「成功」「用户取消」「未识别出文字」「录屏权限未开启」「正在截图中」,杜绝静默吞掉错误;
//! 2. 调用 `CGPreflightScreenCaptureAccess` 预检 macOS 录屏权限,区分无权限导致的空截图与按 Esc 取消;
//! 3. `image.rs` 自动采样截图边缘背景色填充 Padding(替代旧版硬编码纯白底,彻底修复深色模式单行小图识别失败);
//! 4. `vision.rs` 移除主路径上的 `PreferBackgroundProcessing(true)` 后台降级标志,确保菜单栏配件应用极速响应。
pub mod screenshot;
#[cfg(target_os = "macos")]
pub mod image;
#[cfg(target_os = "macos")]
pub mod vision;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
static OCR_BUSY: AtomicBool = AtomicBool::new(false);
struct BusyGuard;
impl BusyGuard {
fn try_acquire() -> Option<Self> {
OCR_BUSY
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.ok()
.map(|_| Self)
}
}
impl Drop for BusyGuard {
fn drop(&mut self) {
OCR_BUSY.store(false, Ordering::SeqCst);
}
}
/// 截图 OCR 强类型结果,彻底避免把权限失败或未识别出文字误判为用户取消。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OcrOutcome {
/// 成功识别出文字。
Text(String),
/// 用户按 Esc 主动取消了区域框选。
Cancelled,
/// 截图成功但所选图片区域内未检测到任何文字。
EmptyText,
/// 上一次截图框选仍在进行中。
Busy,
/// macOS 屏幕录制权限未授予或已失效。
PermissionDenied(String),
}
/// 完整执行一次「macOS 原生交互式框选 (`screencapture -i -x -r`) + Apple Vision OCR」。
pub fn capture_and_recognize() -> Result<OcrOutcome, String> {
let Some(_guard) = BusyGuard::try_acquire() else {
return Ok(OcrOutcome::Busy);
};
match screenshot::capture_interactive()? {
screenshot::CaptureResult::Captured(temp_path) => {
recognize_file(&temp_path.to_string_lossy())
}
screenshot::CaptureResult::Cancelled => Ok(OcrOutcome::Cancelled),
screenshot::CaptureResult::PermissionDenied(msg) => Ok(OcrOutcome::PermissionDenied(msg)),
}
}
#[cfg(target_os = "macos")]
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
vision::recognize_file(path)
}
#[cfg(not(target_os = "macos"))]
pub fn recognize_file(_path: &str) -> Result<OcrOutcome, String> {
Err("当前平台暂不支持 OCR".to_string())
}
+127
View File
@@ -0,0 +1,127 @@
//! macOS 原生屏幕区域框选(`/usr/sbin/screencapture -i -x -r`)与 TCC 录屏权限预检。
use std::path::Path;
use std::process::Command;
use tempfile::{NamedTempFile, TempPath};
#[cfg(target_os = "macos")]
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightScreenCaptureAccess() -> bool;
fn CGRequestScreenCaptureAccess() -> bool;
}
#[cfg(target_os = "macos")]
pub fn has_screen_capture_permission() -> bool {
unsafe { CGPreflightScreenCaptureAccess() }
}
#[cfg(target_os = "macos")]
pub fn request_screen_capture_permission() -> bool {
unsafe { CGRequestScreenCaptureAccess() }
}
#[cfg(not(target_os = "macos"))]
pub fn has_screen_capture_permission() -> bool {
true
}
#[cfg(not(target_os = "macos"))]
pub fn request_screen_capture_permission() -> bool {
true
}
pub fn screencapture_args() -> [&'static str; 3] {
["-i", "-x", "-r"]
}
pub enum CaptureResult {
Captured(TempPath),
Cancelled,
PermissionDenied(String),
}
const PERMISSION_HINT: &str = "未获得 macOS 屏幕录制权限。请打开「系统设置 → 隐私与安全性 → 屏幕与系统音频录制」,开启 TTREE 权限后重试。";
/// 调用 Apple 原生 `/usr/sbin/screencapture -i -x -r` 进行自由框选截图。
pub fn capture_interactive() -> Result<CaptureResult, String> {
let had_permission = has_screen_capture_permission();
if !had_permission {
// 主动请求一次系统权限弹窗,若仍未授权则稍后给出明确提示而非静默返回空
let _ = request_screen_capture_permission();
}
let target = new_capture_target()?;
let output = Command::new("/usr/sbin/screencapture")
.args(screencapture_args())
.arg(target.path())
.output()
.map_err(|e| format!("调用 screencapture 失败: {e}"))?;
let has_capture = validate_capture_output(target.path())?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr);
if !has_capture
&& (detail.trim().is_empty() || detail.to_ascii_lowercase().contains("cancel"))
{
if !had_permission && !has_screen_capture_permission() {
return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string()));
}
return Ok(CaptureResult::Cancelled);
}
let detail: String = detail.trim().chars().take(300).collect();
return Err(format!(
"系统截图失败({}): {detail}。{PERMISSION_HINT}",
output.status
));
}
if !has_capture {
if !had_permission && !has_screen_capture_permission() {
return Ok(CaptureResult::PermissionDenied(PERMISSION_HINT.to_string()));
}
return Ok(CaptureResult::Cancelled);
}
Ok(CaptureResult::Captured(target.into_temp_path()))
}
fn new_capture_target() -> Result<NamedTempFile, String> {
tempfile::Builder::new()
.prefix("ttree-shot-")
.suffix(".png")
.tempfile()
.map_err(|e| format!("创建截图临时文件失败: {e}"))
}
fn validate_capture_output(path: &Path) -> Result<bool, String> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(format!("读取截图临时文件失败: {error}")),
};
if !metadata.file_type().is_file() {
return Err("截图输出不是普通文件".to_string());
}
Ok(metadata.len() > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preserves_native_screencapture_flags() {
assert_eq!(screencapture_args(), ["-i", "-x", "-r"]);
}
#[test]
fn empty_or_deleted_temp_file_is_not_captured() {
let target = new_capture_target().unwrap();
assert!(!validate_capture_output(target.path()).unwrap());
let path = target.path().to_path_buf();
drop(target);
assert!(!validate_capture_output(&path).unwrap());
}
}
+202
View File
@@ -0,0 +1,202 @@
//! macOS Apple Vision (`VNRecognizeTextRequest`) 原生文字识别。
#![cfg(target_os = "macos")]
use objc2::rc::{autoreleasepool, Retained};
use objc2::runtime::AnyObject;
use objc2::{AnyThread, ClassType};
use objc2_core_graphics::CGImage;
use objc2_core_image::{kCIContextUseSoftwareRenderer, CIContext};
use objc2_core_ml::MLCPUComputeDevice;
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSObjectProtocol, NSString};
use objc2_vision::{
VNImageOptionCIContext, VNImageRequestHandler, VNRecognizeTextRequest,
VNRequestTextRecognitionLevel,
};
use crate::OcrOutcome;
pub fn recognize_file(path: &str) -> Result<OcrOutcome, String> {
let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?;
recognize_bytes(&bytes)
}
pub fn recognize_bytes(bytes: &[u8]) -> Result<OcrOutcome, String> {
autoreleasepool(|_| {
let image = super::image::prepare_png(bytes)?;
let first_error = match recognize_once(&image, false) {
Ok(text) if !text.trim().is_empty() => return Ok(OcrOutcome::Text(text)),
Ok(_) => None,
Err(error) => Some(error),
};
// 兼容回退:切换软件 CIContext + CPU 计算设备 + revision 2 再试一次
match recognize_once(&image, true) {
Ok(text) if !text.trim().is_empty() => Ok(OcrOutcome::Text(text)),
Ok(_) => Ok(OcrOutcome::EmptyText),
Err(second_error) => match first_error {
Some(first) => Err(format!("OCR 识别及兼容重试失败: {first}; {second_error}")),
None => Ok(OcrOutcome::EmptyText),
},
}
})
}
pub(crate) fn recognize_once(image: &CGImage, compatibility: bool) -> Result<String, String> {
unsafe {
let request: Retained<VNRecognizeTextRequest> = VNRecognizeTextRequest::new();
configure_request(&request, compatibility)?;
let options = if compatibility || objc2::available!(macos = 27.0) {
let software = NSNumber::numberWithBool(true);
let values: [&AnyObject; 1] = [&software];
let context_options =
NSDictionary::from_slices(&[kCIContextUseSoftwareRenderer], &values);
let context = CIContext::contextWithOptions(Some(&context_options));
let values: [&AnyObject; 1] = [&context];
NSDictionary::from_slices(&[VNImageOptionCIContext], &values)
} else {
NSDictionary::new()
};
let handler = VNImageRequestHandler::initWithCGImage_options(
VNImageRequestHandler::alloc(),
image,
&options,
);
let requests = NSArray::from_retained_slice(&[Retained::into_super(Retained::into_super(
request.clone(),
))]);
handler.performRequests_error(&requests).map_err(|e| {
format!(
"OCR 执行失败: {e}(图像 {}×{},{} bit)",
CGImage::width(Some(image)),
CGImage::height(Some(image)),
CGImage::bits_per_component(Some(image)),
)
})?;
Ok(collect_text(&request))
}
}
fn configure_request(request: &VNRecognizeTextRequest, compatibility: bool) -> Result<(), String> {
request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate);
request.setUsesLanguageCorrection(!compatibility);
let revision = if !compatibility && objc2::available!(macos = 13.0) {
3
} else {
2
};
unsafe {
request.setRevision(revision);
// 关键修复:不再将 preferBackgroundProcessing 设为 true,保证菜单栏隐藏窗口状态下拥有前台级识别优先级
request.setPreferBackgroundProcessing(false);
}
if objc2::available!(macos = 13.0) {
request.setAutomaticallyDetectsLanguage(!compatibility);
}
let supported = unsafe {
if objc2::available!(macos = 12.0) {
request.supportedRecognitionLanguagesAndReturnError()
} else {
#[allow(deprecated)]
VNRecognizeTextRequest::supportedRecognitionLanguagesForTextRecognitionLevel_revision_error(
VNRequestTextRecognitionLevel::Accurate,
revision,
)
}
}
.map_err(|error| format!("读取 OCR 支持语言失败: {error}"))?;
let supported_codes: Vec<String> = supported.iter().map(|code| code.to_string()).collect();
let languages: Vec<Retained<NSString>> = preferred_languages(&supported_codes, revision)
.into_iter()
.map(NSString::from_str)
.collect();
if !languages.is_empty() {
request.setRecognitionLanguages(&NSArray::from_retained_slice(&languages));
}
if compatibility || objc2::available!(macos = 27.0) {
configure_cpu(request)?;
}
Ok(())
}
fn configure_cpu(request: &VNRecognizeTextRequest) -> Result<(), String> {
unsafe {
if objc2::available!(macos = 14.0) {
let stages = request
.supportedComputeStageDevicesAndReturnError()
.map_err(|error| format!("读取 OCR 计算设备失败: {error}"))?;
let mut assigned = false;
for stage in stages.allKeys().iter() {
if let Some(devices) = stages.objectForKey(&stage) {
if let Some(cpu) = devices
.iter()
.find(|device| device.isKindOfClass(MLCPUComputeDevice::class()))
{
request.setComputeDevice_forComputeStage(Some(&cpu), &stage);
assigned = true;
}
}
}
if !assigned {
return Err("此 OCR 模型没有可用的 CPU 计算设备".to_string());
}
} else {
#[allow(deprecated)]
request.setUsesCPUOnly(true);
}
}
Ok(())
}
fn preferred_languages(supported: &[String], revision: usize) -> Vec<&str> {
let candidates: &[&str] = if revision >= 3 {
&[
"zh-Hans", "zh-Hant", "en-US", "ja-JP", "ko-KR", "fr-FR", "de-DE", "es-ES", "ru-RU",
]
} else {
// revision 2 中文模型仅允许与 en-US 组合
&["zh-Hans", "zh-Hant", "en-US"]
};
candidates
.iter()
.copied()
.filter(|code| supported.iter().any(|s| s.as_str() == *code))
.collect()
}
unsafe fn collect_text(request: &VNRecognizeTextRequest) -> String {
let Some(observations) = request.results() else {
return String::new();
};
let mut lines: Vec<String> = Vec::new();
for obs in observations.iter() {
let candidates = obs.topCandidates(1);
if let Some(top) = candidates.firstObject() {
lines.push(top.string().to_string());
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configures_foreground_priority_and_auto_language_detection() {
let request = VNRecognizeTextRequest::new();
configure_request(&request, false).unwrap();
assert!(unsafe { !request.preferBackgroundProcessing() });
if objc2::available!(macos = 13.0) {
assert!(request.automaticallyDetectsLanguage());
}
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "ttree-platform-macos"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
ttree-core = { path = "../ttree-core" }
anyhow.workspace = true
auto-launch.workspace = true
global-hotkey.workspace = true
serde.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation.workspace = true
security-framework.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-core-graphics.workspace = true
objc2-foundation.workspace = true
@@ -0,0 +1,40 @@
//! macOS LaunchAgent 开机自启管理。
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
const APP_NAME: &str = "TTREE";
fn build_auto_launch() -> Result<AutoLaunch, String> {
let exe = std::env::current_exe().map_err(|e| format!("获取当前可执行文件路径失败: {e}"))?;
// 若运行在 TTREE.app/Contents/MacOS/ttree 内,优先传入 .app bundle 路径
let app_path = exe
.parent()
.and_then(|macos| macos.parent())
.and_then(|contents| contents.parent())
.filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app"))
.unwrap_or(exe.as_path());
AutoLaunchBuilder::new()
.set_app_name(APP_NAME)
.set_app_path(&app_path.to_string_lossy())
.set_use_launch_agent(true)
.build()
.map_err(|e| format!("初始化开机自启失败: {e}"))
}
pub fn is_enabled() -> bool {
build_auto_launch()
.and_then(|al| al.is_enabled().map_err(|e| e.to_string()))
.unwrap_or(false)
}
pub fn set_enabled(enable: bool) -> Result<bool, String> {
let al = build_auto_launch()?;
if enable {
al.enable().map_err(|e| format!("启用开机自启失败: {e}"))?;
} else if al.is_enabled().unwrap_or(false) {
al.disable().map_err(|e| format!("关闭开机自启失败: {e}"))?;
}
al.is_enabled()
.map_err(|e| format!("读取开机自启状态失败: {e}"))
}
+180
View File
@@ -0,0 +1,180 @@
//! 全局快捷键解析、冲突校验与内存级 O(1) 分发(不读盘)。
use std::str::FromStr;
use anyhow::anyhow;
use global_hotkey::hotkey::{HotKey, Modifiers};
use ttree_core::ShortcutConfig;
const MAX_SHORTCUT_SPEC_BYTES: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutAction {
Toggle,
Ocr,
AiDialog,
SelectionTranslate,
SelectionAiDialog,
}
#[derive(Debug, Clone, Default)]
pub struct ParsedShortcuts {
pub toggle: Option<HotKey>,
pub ocr: Option<HotKey>,
pub ai_dialog: Option<HotKey>,
pub selection_translate: Option<HotKey>,
pub selection_ai_dialog: Option<HotKey>,
}
impl ParsedShortcuts {
/// 划词动作排在对应的纯呼出动作之前,共用同一快捷键时优先走划词逻辑。
pub fn entries(&self) -> [(ShortcutAction, &Option<HotKey>); 5] {
[
(
ShortcutAction::SelectionTranslate,
&self.selection_translate,
),
(ShortcutAction::SelectionAiDialog, &self.selection_ai_dialog),
(ShortcutAction::Toggle, &self.toggle),
(ShortcutAction::Ocr, &self.ocr),
(ShortcutAction::AiDialog, &self.ai_dialog),
]
}
/// 去重后的待注册热键列表。
pub fn unique_hotkeys(&self) -> Vec<HotKey> {
let mut out: Vec<HotKey> = Vec::new();
for (_, hk) in self.entries() {
if let Some(hk) = hk {
if !out.iter().any(|existing| existing.id() == hk.id()) {
out.push(*hk);
}
}
}
out
}
/// 根据 `global-hotkey` 的 `id` 在内存中 O(1) 匹配动作。
pub fn action_for_id(&self, hotkey_id: u32) -> Option<ShortcutAction> {
self.entries()
.into_iter()
.find(|(_, candidate)| candidate.as_ref().is_some_and(|hk| hk.id() == hotkey_id))
.map(|(action, _)| action)
}
fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> {
let fields: [(&str, &Option<HotKey>); 5] = [
("呼出翻译快捷键", &self.toggle),
("截图 OCR 快捷键", &self.ocr),
("呼出 AI 对话快捷键", &self.ai_dialog),
("划词翻译快捷键", &self.selection_translate),
("划词 AI 对话快捷键", &self.selection_ai_dialog),
];
for (i, (left_label, left)) in fields.iter().enumerate() {
let Some(left) = left else { continue };
for (right_label, right) in fields.iter().skip(i + 1) {
let Some(right) = right else { continue };
if left.id() == right.id() && !is_allowed_shared_pair(left_label, right_label) {
return Err(anyhow!("{left_label}和{right_label}不能相同"));
}
}
}
Ok(())
}
}
fn is_allowed_shared_pair(a: &str, b: &str) -> bool {
const ALLOWED: [(&str, &str); 2] = [
("呼出翻译快捷键", "划词翻译快捷键"),
("呼出 AI 对话快捷键", "划词 AI 对话快捷键"),
];
ALLOWED
.iter()
.any(|&(x, y)| (a == x && b == y) || (a == y && b == x))
}
/// 将 Tauri 风格的 `CmdOrCtrl+Shift+Space` / `Option+Space` 规范化为 `global-hotkey` 支持的格式。
fn normalize_spec(spec: &str) -> String {
spec.trim()
.split('+')
.map(|part| match part.trim() {
"CmdOrCtrl" | "CommandOrControl" | "Cmd" | "Command" => "Super",
"Option" => "Alt",
other => other,
})
.collect::<Vec<_>>()
.join("+")
}
pub fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result<ParsedShortcuts, anyhow::Error> {
let parsed = ParsedShortcuts {
toggle: parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?,
ocr: parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?,
ai_dialog: parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?,
selection_translate: parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?,
selection_ai_dialog: parse_optional_shortcut(
"划词 AI 对话快捷键",
&cfg.selection_ai_dialog,
)?,
};
parsed.reject_disallowed_duplicates()?;
Ok(parsed)
}
fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<HotKey>, anyhow::Error> {
if spec.len() > MAX_SHORTCUT_SPEC_BYTES {
return Err(anyhow!("{label}超出 {MAX_SHORTCUT_SPEC_BYTES} 字节上限"));
}
if spec.trim().is_empty() {
return Ok(None);
}
let normalized = normalize_spec(spec);
let hotkey = HotKey::from_str(&normalized).map_err(|e| anyhow!("{label}无效: {e}"))?;
if !hotkey
.mods
.intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT)
{
return Err(anyhow!("{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
}
Ok(Some(hotkey))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_and_shared_shortcuts() {
let cfg = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+Space".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
ai_dialog: "Option+Space".to_string(),
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
selection_ai_dialog: String::new(),
};
let parsed = parse_shortcut_config(&cfg).unwrap();
assert_eq!(parsed.unique_hotkeys().len(), 3);
let toggle_id = parsed.toggle.unwrap().id();
assert_eq!(
parsed.action_for_id(toggle_id),
Some(ShortcutAction::SelectionTranslate)
);
}
#[test]
fn rejects_invalid_or_conflicting_shortcuts() {
let no_mod = ShortcutConfig {
toggle: "Space".to_string(),
..ShortcutConfig::default()
};
assert!(parse_shortcut_config(&no_mod).is_err());
let conflict = ShortcutConfig {
toggle: "CmdOrCtrl+Shift+S".to_string(),
ocr: "CmdOrCtrl+Shift+S".to_string(),
..ShortcutConfig::default()
};
assert!(parse_shortcut_config(&conflict).is_err());
}
}
+127
View File
@@ -0,0 +1,127 @@
//! macOS Keychain 封装 —— 兼容 `com.samwstu.ttree` 既有凭据格式。
#[cfg(target_os = "macos")]
use security_framework::passwords;
use serde::{Deserialize, Serialize};
pub const SERVICE: &str = "com.samwstu.ttree";
pub const ACCOUNT_OPENAI_KEY: &str = "openai.api_key";
pub const ACCOUNT_CHAT_AI_KEY: &str = "chat_ai.api_key";
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
const BOUND_SECRET_PREFIX: &str = "ttree-bound-secret-v1:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretRecord {
Legacy(String),
Bound { endpoint: String, value: String },
}
impl SecretRecord {
pub fn value(&self) -> &str {
match self {
Self::Legacy(v) => v,
Self::Bound { value, .. } => value,
}
}
}
#[derive(Serialize, Deserialize)]
struct BoundSecretPayload {
endpoint: String,
value: String,
}
pub fn encode_bound_secret(endpoint: &str, value: &str) -> Result<String, String> {
if endpoint.is_empty() || value.is_empty() {
return Err("接口地址和密钥均不能为空".to_string());
}
let payload = serde_json::to_string(&BoundSecretPayload {
endpoint: endpoint.to_string(),
value: value.to_string(),
})
.map_err(|e| format!("序列化钥匙串记录失败: {e}"))?;
Ok(format!("{BOUND_SECRET_PREFIX}{payload}"))
}
pub fn decode_secret_record(raw: &str) -> Result<SecretRecord, String> {
let Some(payload) = raw.strip_prefix(BOUND_SECRET_PREFIX) else {
return Ok(SecretRecord::Legacy(raw.to_string()));
};
let record: BoundSecretPayload =
serde_json::from_str(payload).map_err(|e| format!("钥匙串记录损坏: {e}"))?;
if record.endpoint.is_empty() || record.value.is_empty() {
return Err("钥匙串记录损坏: 接口地址或密钥为空".to_string());
}
Ok(SecretRecord::Bound {
endpoint: record.endpoint,
value: record.value,
})
}
pub fn get_secret_record(account: &str) -> Result<Option<SecretRecord>, String> {
get_secret(account)?
.map(|raw| decode_secret_record(&raw))
.transpose()
}
pub fn set_bound_secret(account: &str, endpoint: &str, value: &str) -> Result<(), String> {
let encoded = encode_bound_secret(endpoint, value)?;
set_secret(account, &encoded)
}
#[cfg(target_os = "macos")]
pub fn get_secret(account: &str) -> Result<Option<String>, String> {
match passwords::get_generic_password(SERVICE, account) {
Ok(bytes) => Ok(Some(
String::from_utf8(bytes).map_err(|e| format!("密钥内容非 UTF-8: {e}"))?,
)),
Err(err) => {
if err.code() == ERR_SEC_ITEM_NOT_FOUND {
Ok(None)
} else {
Err(format!("读取钥匙串失败 (code {}): {err}", err.code()))
}
}
}
}
#[cfg(target_os = "macos")]
pub fn set_secret(account: &str, value: &str) -> Result<(), String> {
passwords::set_generic_password(SERVICE, account, value.as_bytes())
.map_err(|e| format!("写入钥匙串失败: {e}"))
}
#[cfg(target_os = "macos")]
pub fn delete_secret(account: &str) -> Result<(), String> {
match passwords::delete_generic_password(SERVICE, account) {
Ok(()) => Ok(()),
Err(err) if err.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()),
Err(err) => Err(format!("删除钥匙串失败 (code {}): {err}", err.code())),
}
}
#[cfg(not(target_os = "macos"))]
pub fn get_secret(_account: &str) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(not(target_os = "macos"))]
pub fn set_secret(_account: &str, _value: &str) -> Result<(), String> {
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn delete_secret(_account: &str) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_bound_secret() {
let encoded = encode_bound_secret("https://api.openai.com/v1", "sk-test").unwrap();
let decoded = decode_secret_record(&encoded).unwrap();
assert_eq!(decoded.value(), "sk-test");
}
}
+10
View File
@@ -0,0 +1,10 @@
//! `ttree-platform-macos` —— macOS 原生系统能力封装:
//! - Keychain 安全凭据存取(兼容既有 `com.samwstu.ttree` 钥匙串记录)
//! - Accessibility + 剪贴板回退的划词捕获
//! - 全局热键解析、冲突校验与内存级 O(1) 分发
//! - LaunchAgent 开机自启管理
pub mod autostart;
pub mod hotkey;
pub mod keychain;
pub mod selection;
@@ -0,0 +1,253 @@
//! macOS 选中文本捕获:优先走 Accessibility API (`AXSelectedText`),
//! 仅当辅助功能未授权或焦点控件不支持 AX 属性时才回退到临时剪贴板复制。
use anyhow::Result;
pub fn normalize_selected_text(text: impl AsRef<str>) -> Option<String> {
let trimmed = text.as_ref().trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(target_os = "macos")]
mod imp {
use std::ptr;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType};
use core_foundation::string::{CFString, CFStringRef};
use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString};
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation};
use objc2_foundation::{NSData, NSInteger, NSString};
use super::normalize_selected_text;
const AX_ERROR_SUCCESS: i32 = 0;
const AX_ERROR_NO_VALUE: i32 = -25212;
const COPY_KEY_CODE: u16 = 8;
const COPY_TIMEOUT: Duration = Duration::from_millis(320);
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(16);
#[repr(C)]
struct __AXUIElement {
_private: [u8; 0],
}
type AXUIElementRef = *const __AXUIElement;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> Boolean;
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: CFStringRef,
value: *mut CFTypeRef,
) -> i32;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AxOutcome {
Selected(String),
EmptySelection,
Unavailable,
}
#[derive(Debug, Clone)]
struct PasteboardSnapshot {
entries: Vec<PasteboardEntry>,
}
#[derive(Debug, Clone)]
struct PasteboardEntry {
type_name: String,
data: Vec<u8>,
}
pub fn is_accessibility_trusted() -> bool {
unsafe { AXIsProcessTrusted() != 0 }
}
pub fn capture_selected_text() -> Result<Option<String>> {
match capture_accessibility() {
AxOutcome::Selected(text) => Ok(Some(text)),
// Accessibility 已明确告知当前焦点控件为空选区:不合成 Cmd+C,避免误触蜂鸣音与 320ms 轮询等待
AxOutcome::EmptySelection => Ok(None),
AxOutcome::Unavailable => capture_clipboard_selected_text(),
}
}
fn capture_accessibility() -> AxOutcome {
unsafe {
if AXIsProcessTrusted() == 0 {
return AxOutcome::Unavailable;
}
let system_element = AXUIElementCreateSystemWide();
if system_element.is_null() {
return AxOutcome::Unavailable;
}
let focused_attr = CFString::new("AXFocusedUIElement");
let mut focused_value: CFTypeRef = ptr::null();
let focused_error = AXUIElementCopyAttributeValue(
system_element,
focused_attr.as_concrete_TypeRef(),
&mut focused_value,
);
CFRelease(system_element as CFTypeRef);
if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() {
return AxOutcome::Unavailable;
}
let selected_attr = CFString::new("AXSelectedText");
let mut selected_value: CFTypeRef = ptr::null();
let selected_error = AXUIElementCopyAttributeValue(
focused_value as AXUIElementRef,
selected_attr.as_concrete_TypeRef(),
&mut selected_value,
);
CFRelease(focused_value);
if selected_error == AX_ERROR_NO_VALUE {
return AxOutcome::EmptySelection;
}
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
return AxOutcome::Unavailable;
}
let selected =
CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
match normalize_selected_text(selected) {
Some(text) => AxOutcome::Selected(text),
None => AxOutcome::EmptySelection,
}
}
}
fn capture_clipboard_selected_text() -> Result<Option<String>> {
let pasteboard = NSPasteboard::generalPasteboard();
let snapshot = snapshot_pasteboard(&pasteboard);
let before_copy = pasteboard.changeCount();
post_copy_keypress()?;
let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else {
return Ok(None);
};
let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text);
if pasteboard.changeCount() == copy_change_count {
restore_pasteboard(&pasteboard, &snapshot);
}
Ok(copied_text)
}
fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot {
let entries = pasteboard
.types()
.map(|types| {
types
.to_vec()
.into_iter()
.filter_map(|type_name| {
pasteboard
.dataForType(&type_name)
.map(|data| PasteboardEntry {
type_name: type_name.to_string(),
data: data.to_vec(),
})
})
.collect()
})
.unwrap_or_default();
PasteboardSnapshot { entries }
}
fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) {
let _ = pasteboard.clearContents();
for entry in &snapshot.entries {
let type_name = NSString::from_str(&entry.type_name);
let data = NSData::with_bytes(&entry.data);
let _ = pasteboard.setData_forType(Some(&data), &type_name);
}
}
fn wait_for_pasteboard_change(
pasteboard: &NSPasteboard,
before_copy: NSInteger,
) -> Option<NSInteger> {
let deadline = Instant::now() + COPY_TIMEOUT;
while Instant::now() < deadline {
let current = pasteboard.changeCount();
if current != before_copy {
return Some(current);
}
thread::sleep(COPY_POLL_INTERVAL);
}
None
}
fn read_plain_text(pasteboard: &NSPasteboard) -> Option<String> {
let pb_type: &'static NSPasteboardType = unsafe { NSPasteboardTypeString };
pasteboard
.stringForType(pb_type)
.map(|text| text.to_string())
}
fn post_copy_keypress() -> Result<()> {
let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true)
.ok_or_else(|| anyhow!("创建复制按下事件失败"))?;
let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false)
.ok_or_else(|| anyhow!("创建复制释放事件失败"))?;
CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand);
CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand);
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
Ok(())
}
}
#[cfg(target_os = "macos")]
pub fn capture_selected_text() -> Result<Option<String>> {
imp::capture_selected_text()
}
#[cfg(target_os = "macos")]
pub fn is_accessibility_trusted() -> bool {
imp::is_accessibility_trusted()
}
#[cfg(not(target_os = "macos"))]
pub fn capture_selected_text() -> Result<Option<String>> {
Ok(None)
}
#[cfg(not(target_os = "macos"))]
pub fn is_accessibility_trusted() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_whitespace_only_to_none() {
assert_eq!(normalize_selected_text(" \n\t "), None);
assert_eq!(
normalize_selected_text(" hello world \n"),
Some("hello world".to_string())
);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "ttree-storage"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
ttree-core = { path = "../ttree-core" }
ttree-platform-macos = { path = "../ttree-platform-macos" }
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
+238
View File
@@ -0,0 +1,238 @@
//! AI 对话历史持久化(JSONL 追加写,100% 兼容既有 `chat-history.jsonl`)。
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::Deserialize;
use ttree_core::{validate_messages, ChatMessage, Conversation};
use crate::settings::SettingsStore;
pub const HISTORY_FILE: &str = "chat-history.jsonl";
const SESSION_START_MARKER: &str = r#"{"type":"session_start"}"#;
const SUMMARY_MAX_CHARS: usize = 40;
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum HistoryLine {
SessionResume {
#[serde(rename = "type")]
kind: String,
id: usize,
},
SessionStart {
#[serde(rename = "type")]
kind: String,
},
Message(ChatMessage),
}
pub struct HistoryStore {
path: PathBuf,
lock: Mutex<()>,
}
impl HistoryStore {
pub fn open_default() -> Self {
let dir = SettingsStore::default_config_dir();
let _ = fs::create_dir_all(&dir);
Self::open_at(dir.join(HISTORY_FILE))
}
pub fn open_at(path: PathBuf) -> Self {
Self {
path,
lock: Mutex::new(()),
}
}
pub fn start_new_conversation(&self) -> Result<(), String> {
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
let needs_marker = fs::metadata(&self.path)
.map(|m| m.len() > 0)
.unwrap_or(false);
if !needs_marker {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|e| format!("打开历史文件失败: {e}"))?;
let mut buf = String::from(SESSION_START_MARKER);
buf.push('\n');
file.write_all(buf.as_bytes())
.map_err(|e| format!("写入对话分隔标记失败: {e}"))
}
pub fn append_round(
&self,
conversation_id: Option<usize>,
msgs: &[ChatMessage],
) -> Result<(), String> {
if msgs.is_empty() {
return Ok(());
}
validate_messages(msgs)?;
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
let mut buf = String::with_capacity(128 * msgs.len());
if let Some(id) = conversation_id {
let conversations = read_conversations_from_path(&self.path)?;
if !conversations.iter().any(|c| c.id == id) {
return Err(format!("对话不存在: {id}"));
}
let marker = serde_json::json!({ "type": "session_resume", "id": id });
buf.push_str(
&serde_json::to_string(&marker)
.map_err(|e| format!("序列化对话恢复标记失败: {e}"))?,
);
buf.push('\n');
}
for m in msgs {
let line = serde_json::to_string(m).map_err(|e| format!("序列化消息失败: {e}"))?;
buf.push_str(&line);
buf.push('\n');
}
if let Some(parent) = self.path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|e| format!("打开历史文件失败: {e}"))?;
file.write_all(buf.as_bytes())
.map_err(|e| format!("写入历史文件失败: {e}"))
}
pub fn read_conversations(&self) -> Result<Vec<Conversation>, String> {
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
read_conversations_from_path(&self.path)
}
pub fn clear(&self) -> Result<(), String> {
let _guard = self.lock.lock().map_err(|e| format!("历史锁中毒: {e}"))?;
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("删除历史文件失败: {e}")),
}
}
}
fn read_conversations_from_path(path: &Path) -> Result<Vec<Conversation>, String> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("打开历史文件失败: {e}")),
};
let reader = BufReader::new(file);
Ok(parse_conversation_lines(
reader.lines().map_while(Result::ok),
))
}
pub fn parse_conversation_lines<I>(lines: I) -> Vec<Conversation>
where
I: IntoIterator<Item = String>,
{
let mut conversations: Vec<Conversation> = Vec::new();
let mut current_id: Option<usize> = None;
let mut next_id: usize = 0;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(parsed) = serde_json::from_str::<HistoryLine>(trimmed) else {
continue;
};
match parsed {
HistoryLine::SessionResume { kind, id } if kind == "session_resume" => {
current_id = Some(id);
next_id = next_id.max(id.saturating_add(1));
if let Some(index) = conversations.iter().position(|c| c.id == id) {
let conv = conversations.remove(index);
conversations.push(conv);
}
}
HistoryLine::SessionStart { kind } if kind == "session_start" => {
current_id = Some(next_id);
next_id = next_id.saturating_add(1);
}
HistoryLine::Message(msg) => {
let id = current_id.unwrap_or_else(|| {
let id = next_id;
next_id = next_id.saturating_add(1);
current_id = Some(id);
id
});
if !conversations.iter().any(|c| c.id == id) {
conversations.push(Conversation {
id,
messages: Vec::new(),
summary: String::new(),
});
}
let conv = conversations
.iter_mut()
.find(|c| c.id == id)
.expect("conversation exists");
if conv.summary.is_empty() && msg.role == "user" {
conv.summary = truncate_summary(&msg.content);
}
conv.messages.push(msg);
}
_ => continue,
}
}
conversations
}
fn truncate_summary(content: &str) -> String {
let first_line = content.lines().next().unwrap_or("").trim();
if first_line.chars().count() <= SUMMARY_MAX_CHARS {
return first_line.to_string();
}
let truncated: String = first_line.chars().take(SUMMARY_MAX_CHARS).collect();
format!("{truncated}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_conversations_and_session_splits() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let store = HistoryStore::open_at(tmp.path().to_path_buf());
store
.append_round(
None,
&[
ChatMessage::user("第一段问题"),
ChatMessage::assistant("第一段回答"),
],
)
.unwrap();
store.start_new_conversation().unwrap();
store
.append_round(
None,
&[
ChatMessage::user("第二段问题"),
ChatMessage::assistant("第二段回答"),
],
)
.unwrap();
let convs = store.read_conversations().unwrap();
assert_eq!(convs.len(), 2);
assert_eq!(convs[0].summary, "第一段问题");
assert_eq!(convs[1].summary, "第二段问题");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! `ttree-storage` —— 内存级配置缓存(兼容既有 `settings.json` 与 Keychain)与 JSONL 对话历史持久化。
pub mod history;
pub mod settings;
pub use history::HistoryStore;
pub use settings::SettingsStore;
+442
View File
@@ -0,0 +1,442 @@
//! 内存缓存 + 磁盘持久化的配置管理。
//!
//! 关键性能优化:
//! - 启动时一次性加载 `settings.json` 与 Keychain 密钥到内存 `RwLock`
//! - 翻译/对话/快捷键分发热路径直接从内存读取(0 磁盘 I/O,0 Keychain IPC)
//! - 100% 兼容旧版 Tauri `tauri-plugin-store` 生成的扁平点号键名(如 `"openai.base_url"`)
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use serde_json::{Map, Value};
use ttree_core::config::{
validate_field_len, MAX_API_KEY_BYTES, MAX_MODEL_BYTES, MAX_PATH_BYTES, MAX_PROMPT_BYTES,
};
use ttree_core::{
normalize_base_url, AiBackendKind, AiTranslationConfig, AppearanceConfig, ChatAiConfig,
ShortcutConfig, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH,
};
use ttree_platform_macos::keychain::{self, ACCOUNT_CHAT_AI_KEY, ACCOUNT_OPENAI_KEY};
pub const APP_BUNDLE_ID: &str = "com.samwstu.ttree";
pub const STORE_FILE: &str = "settings.json";
#[derive(Clone)]
struct CachedSettings {
raw_map: Map<String, Value>,
ai_translate: AiTranslationConfig,
chat_ai: ChatAiConfig,
shortcuts: ShortcutConfig,
appearance: AppearanceConfig,
window_size: (f64, f64),
}
pub struct SettingsStore {
path: PathBuf,
cache: RwLock<CachedSettings>,
}
impl SettingsStore {
pub fn default_config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(APP_BUNDLE_ID)
}
pub fn open_default() -> Self {
let dir = Self::default_config_dir();
let _ = fs::create_dir_all(&dir);
Self::open_at(dir.join(STORE_FILE))
}
pub fn open_at(path: PathBuf) -> Self {
let raw_map = read_json_map(&path);
let ai_translate = load_ai_translation(&raw_map);
let chat_ai = load_chat_ai(&raw_map);
let shortcuts = load_shortcuts(&raw_map);
let appearance = load_appearance(&raw_map);
let window_size = load_window_size(&raw_map).unwrap_or((680.0, 520.0));
Self {
path,
cache: RwLock::new(CachedSettings {
raw_map,
ai_translate,
chat_ai,
shortcuts,
appearance,
window_size,
}),
}
}
/// 供翻译请求直接读取(含内存缓存的真实 API Key,零 I/O)。
pub fn ai_translation_for_request(&self) -> AiTranslationConfig {
self.cache.read().expect("settings lock poisoned").ai_translate.clone()
}
/// 供对话请求直接读取(含内存缓存的真实 API Key,零 I/O)。
pub fn chat_ai_for_request(&self) -> ChatAiConfig {
self.cache.read().expect("settings lock poisoned").chat_ai.clone()
}
pub fn shortcuts(&self) -> ShortcutConfig {
self.cache.read().expect("settings lock poisoned").shortcuts.clone()
}
pub fn appearance(&self) -> AppearanceConfig {
self.cache.read().expect("settings lock poisoned").appearance
}
pub fn window_size(&self) -> (f64, f64) {
self.cache.read().expect("settings lock poisoned").window_size
}
pub fn save_ai_translation(&self, input: &AiTranslationConfig) -> Result<(), String> {
validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?;
validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?;
validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?;
validate_field_len("翻译提示词", &input.translate_prompt, MAX_PROMPT_BYTES)?;
let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() {
String::new()
} else {
normalize_base_url(&input.base_url)?
};
let mut guard = self.cache.write().expect("settings lock poisoned");
let resolved_key = update_secret_if_needed(
ACCOUNT_OPENAI_KEY,
&base_url,
&guard.ai_translate.api_key,
&input.api_key,
input.clear_api_key,
)?;
let updated = AiTranslationConfig {
enabled: input.enabled,
backend: input.backend,
base_url: base_url.clone(),
has_api_key: !resolved_key.is_empty(),
api_key: resolved_key,
clear_api_key: false,
model: input.model.trim().to_string(),
gguf_model_path: input.gguf_model_path.trim().to_string(),
gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(),
translate_prompt: input.translate_prompt.clone(),
};
guard.raw_map.insert("openai.enabled".into(), Value::Bool(updated.enabled));
guard.raw_map.insert(
"openai.backend".into(),
serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())),
);
guard.raw_map.insert("openai.base_url".into(), Value::String(updated.base_url.clone()));
guard.raw_map.insert("openai.model".into(), Value::String(updated.model.clone()));
guard.raw_map.insert(
"openai.gguf_model_path".into(),
Value::String(updated.gguf_model_path.clone()),
);
guard.raw_map.insert(
"openai.gguf_tokenizer_path".into(),
Value::String(updated.gguf_tokenizer_path.clone()),
);
guard.raw_map.insert(
"openai.translate_prompt".into(),
Value::String(updated.translate_prompt.clone()),
);
guard.raw_map.remove("openai.api_key");
write_json_map(&self.path, &guard.raw_map)?;
guard.ai_translate = updated;
Ok(())
}
pub fn save_chat_ai(&self, input: &ChatAiConfig) -> Result<(), String> {
validate_field_len("模型", &input.model, MAX_MODEL_BYTES)?;
validate_field_len("GGUF 模型路径", &input.gguf_model_path, MAX_PATH_BYTES)?;
validate_field_len("Tokenizer 路径", &input.gguf_tokenizer_path, MAX_PATH_BYTES)?;
validate_field_len("AI 对话提示词", &input.chat_prompt, MAX_PROMPT_BYTES)?;
let base_url = if input.backend == AiBackendKind::CandleGguf && input.base_url.trim().is_empty() {
String::new()
} else {
normalize_base_url(&input.base_url)?
};
let mut guard = self.cache.write().expect("settings lock poisoned");
let resolved_key = update_secret_if_needed(
ACCOUNT_CHAT_AI_KEY,
&base_url,
&guard.chat_ai.api_key,
&input.api_key,
input.clear_api_key,
)?;
let updated = ChatAiConfig {
backend: input.backend,
base_url: base_url.clone(),
has_api_key: !resolved_key.is_empty(),
api_key: resolved_key,
clear_api_key: false,
model: input.model.trim().to_string(),
gguf_model_path: input.gguf_model_path.trim().to_string(),
gguf_tokenizer_path: input.gguf_tokenizer_path.trim().to_string(),
chat_prompt: input.chat_prompt.clone(),
};
guard.raw_map.insert(
"chat_ai.backend".into(),
serde_json::to_value(updated.backend).unwrap_or(Value::String("open_ai_compatible".into())),
);
guard.raw_map.insert("chat_ai.base_url".into(), Value::String(updated.base_url.clone()));
guard.raw_map.insert("chat_ai.model".into(), Value::String(updated.model.clone()));
guard.raw_map.insert(
"chat_ai.gguf_model_path".into(),
Value::String(updated.gguf_model_path.clone()),
);
guard.raw_map.insert(
"chat_ai.gguf_tokenizer_path".into(),
Value::String(updated.gguf_tokenizer_path.clone()),
);
guard.raw_map.insert(
"chat_ai.chat_prompt".into(),
Value::String(updated.chat_prompt.clone()),
);
guard.raw_map.remove("chat_ai.api_key");
write_json_map(&self.path, &guard.raw_map)?;
guard.chat_ai = updated;
Ok(())
}
pub fn save_shortcuts(&self, cfg: &ShortcutConfig) -> Result<(), String> {
let mut guard = self.cache.write().expect("settings lock poisoned");
guard.raw_map.insert("shortcut.toggle".into(), Value::String(cfg.toggle.clone()));
guard.raw_map.insert("shortcut.ocr".into(), Value::String(cfg.ocr.clone()));
guard.raw_map.insert("shortcut.ai_dialog".into(), Value::String(cfg.ai_dialog.clone()));
guard.raw_map.insert(
"shortcut.selection_translate".into(),
Value::String(cfg.selection_translate.clone()),
);
guard.raw_map.insert(
"shortcut.selection_ai_dialog".into(),
Value::String(cfg.selection_ai_dialog.clone()),
);
write_json_map(&self.path, &guard.raw_map)?;
guard.shortcuts = cfg.clone();
Ok(())
}
pub fn save_appearance(&self, cfg: AppearanceConfig) -> Result<(), String> {
let clamped = AppearanceConfig {
panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity),
};
let mut guard = self.cache.write().expect("settings lock poisoned");
guard
.raw_map
.insert("appearance.panel_opacity".into(), Value::from(clamped.panel_opacity));
write_json_map(&self.path, &guard.raw_map)?;
guard.appearance = clamped;
Ok(())
}
pub fn save_window_size(&self, width: f64, height: f64) {
let w = width.max(WINDOW_MIN_WIDTH);
let h = height.max(WINDOW_MIN_HEIGHT);
if let Ok(mut guard) = self.cache.write() {
if (guard.window_size.0 - w).abs() < 1.0 && (guard.window_size.1 - h).abs() < 1.0 {
return;
}
guard.window_size = (w, h);
guard.raw_map.insert("window.width".into(), Value::from(w));
guard.raw_map.insert("window.height".into(), Value::from(h));
let _ = write_json_map(&self.path, &guard.raw_map);
}
}
}
fn update_secret_if_needed(
account: &str,
endpoint: &str,
existing: &str,
new_key: &str,
clear: bool,
) -> Result<String, String> {
let trimmed = new_key.trim();
validate_field_len("API Key", trimmed, MAX_API_KEY_BYTES)?;
if clear {
let _ = keychain::delete_secret(account);
return Ok(String::new());
}
if !trimmed.is_empty() {
let bind_url = if endpoint.is_empty() {
"local://gguf"
} else {
endpoint
};
let _ = keychain::set_bound_secret(account, bind_url, trimmed);
return Ok(trimmed.to_string());
}
Ok(existing.to_string())
}
fn read_json_map(path: &Path) -> Map<String, Value> {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<Map<String, Value>>(&s).ok())
.unwrap_or_default()
}
fn write_json_map(path: &Path, map: &Map<String, Value>) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?;
}
let bytes = serde_json::to_vec_pretty(map).map_err(|e| format!("序列化配置失败: {e}"))?;
fs::write(path, bytes).map_err(|e| format!("写入配置文件失败: {e}"))
}
fn load_ai_translation(map: &Map<String, Value>) -> AiTranslationConfig {
let mut cfg = AiTranslationConfig::default();
if let Some(enabled) = map.get("openai.enabled").and_then(Value::as_bool) {
cfg.enabled = enabled;
}
if let Some(backend) = map
.get("openai.backend")
.and_then(|v| serde_json::from_value::<AiBackendKind>(v.clone()).ok())
{
cfg.backend = backend;
}
if let Some(url) = map.get("openai.base_url").and_then(Value::as_str) {
if let Ok(norm) = normalize_base_url(url) {
cfg.base_url = norm;
}
}
if let Some(m) = map.get("openai.model").and_then(Value::as_str) {
if !m.trim().is_empty() {
cfg.model = m.trim().to_string();
}
}
if let Some(p) = map.get("openai.gguf_model_path").and_then(Value::as_str) {
cfg.gguf_model_path = p.trim().to_string();
}
if let Some(p) = map.get("openai.gguf_tokenizer_path").and_then(Value::as_str) {
cfg.gguf_tokenizer_path = p.trim().to_string();
}
if let Some(prompt) = map.get("openai.translate_prompt").and_then(Value::as_str) {
if !prompt.trim().is_empty() {
cfg.translate_prompt = prompt.to_string();
}
}
if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_OPENAI_KEY) {
cfg.api_key = record.value().to_string();
cfg.has_api_key = !cfg.api_key.is_empty();
}
cfg
}
fn load_chat_ai(map: &Map<String, Value>) -> ChatAiConfig {
let mut cfg = ChatAiConfig::default();
if let Some(backend) = map
.get("chat_ai.backend")
.and_then(|v| serde_json::from_value::<AiBackendKind>(v.clone()).ok())
{
cfg.backend = backend;
}
if let Some(url) = map.get("chat_ai.base_url").and_then(Value::as_str) {
if let Ok(norm) = normalize_base_url(url) {
cfg.base_url = norm;
}
}
if let Some(m) = map.get("chat_ai.model").and_then(Value::as_str) {
if !m.trim().is_empty() {
cfg.model = m.trim().to_string();
}
}
if let Some(p) = map.get("chat_ai.gguf_model_path").and_then(Value::as_str) {
cfg.gguf_model_path = p.trim().to_string();
}
if let Some(p) = map.get("chat_ai.gguf_tokenizer_path").and_then(Value::as_str) {
cfg.gguf_tokenizer_path = p.trim().to_string();
}
if let Some(prompt) = map.get("chat_ai.chat_prompt").and_then(Value::as_str) {
cfg.chat_prompt = prompt.to_string();
}
if let Ok(Some(record)) = keychain::get_secret_record(ACCOUNT_CHAT_AI_KEY) {
cfg.api_key = record.value().to_string();
cfg.has_api_key = !cfg.api_key.is_empty();
}
cfg
}
fn load_shortcuts(map: &Map<String, Value>) -> ShortcutConfig {
let defaults = ShortcutConfig::default();
ShortcutConfig {
toggle: map
.get("shortcut.toggle")
.and_then(Value::as_str)
.unwrap_or(&defaults.toggle)
.to_string(),
ocr: map
.get("shortcut.ocr")
.and_then(Value::as_str)
.unwrap_or(&defaults.ocr)
.to_string(),
ai_dialog: map
.get("shortcut.ai_dialog")
.and_then(Value::as_str)
.unwrap_or(&defaults.ai_dialog)
.to_string(),
selection_translate: map
.get("shortcut.selection_translate")
.and_then(Value::as_str)
.unwrap_or(&defaults.selection_translate)
.to_string(),
selection_ai_dialog: map
.get("shortcut.selection_ai_dialog")
.and_then(Value::as_str)
.unwrap_or(&defaults.selection_ai_dialog)
.to_string(),
}
}
fn load_appearance(map: &Map<String, Value>) -> AppearanceConfig {
let mut cfg = AppearanceConfig::default();
if let Some(v) = map.get("appearance.panel_opacity").and_then(Value::as_f64) {
cfg.panel_opacity = AppearanceConfig::clamp_opacity(v);
}
cfg
}
fn load_window_size(map: &Map<String, Value>) -> Option<(f64, f64)> {
let w = map.get("window.width").and_then(Value::as_f64)?;
let h = map.get("window.height").and_then(Value::as_f64)?;
if w < WINDOW_MIN_WIDTH || h < WINDOW_MIN_HEIGHT {
return None;
}
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persists_and_reloads_settings_via_memory_cache() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let store = SettingsStore::open_at(tmp.path().to_path_buf());
let mut cfg = store.ai_translation_for_request();
cfg.backend = AiBackendKind::Ollama;
cfg.base_url = "http://127.0.0.1:11434".to_string();
cfg.model = "qwen2.5:3b".to_string();
store.save_ai_translation(&cfg).unwrap();
let reloaded = SettingsStore::open_at(tmp.path().to_path_buf());
let loaded_cfg = reloaded.ai_translation_for_request();
assert_eq!(loaded_cfg.backend, AiBackendKind::Ollama);
assert_eq!(loaded_cfg.base_url, "http://127.0.0.1:11434");
assert_eq!(loaded_cfg.model, "qwen2.5:3b");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ttree-translate"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
ttree-core = { path = "../ttree-core" }
futures-util.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
urlencoding.workspace = true
+253
View File
@@ -0,0 +1,253 @@
//! 免费网页翻译智能竞速路由(Hedged Request Racing + 延迟加权主备优选 + 并发分块)。
//!
//! 关键提速:
//! 1. 根据最近一次响应延迟选出最快的服务作为 Primary,另一方作为 Secondary;
//! 2. Primary 发出后若在 `HEDGE_DELAY (550ms)` 内未返回,立即并发启动 Secondary 竞速(谁先成功用谁);
//! 3. 长文本分块后支持并发请求,而非旧版的串行逐块阻塞等待。
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use futures_util::future::join_all;
use crate::{bing, google};
const HEDGE_DELAY: Duration = Duration::from_millis(550);
const BASE_COOLDOWN: Duration = Duration::from_secs(45);
const MAX_COOLDOWN: Duration = Duration::from_secs(300);
const CHUNK_UTF16_LIMIT: usize = 1_200;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Provider {
Google,
Bing,
}
impl Provider {
fn index(self) -> usize {
match self {
Self::Google => 0,
Self::Bing => 1,
}
}
fn other(self) -> Self {
match self {
Self::Google => Self::Bing,
Self::Bing => Self::Google,
}
}
}
#[derive(Clone, Copy)]
struct ProviderStats {
ewma_ms: u64,
failures: u32,
retry_after: Option<Instant>,
}
impl Default for ProviderStats {
fn default() -> Self {
Self {
ewma_ms: 400,
failures: 0,
retry_after: None,
}
}
}
#[derive(Default)]
struct RouterState {
stats: [ProviderStats; 2],
}
impl RouterState {
fn is_available(&self, p: Provider, now: Instant) -> bool {
self.stats[p.index()]
.retry_after
.is_none_or(|t| now >= t)
}
/// 优先返回当前可用且历史延迟更低的服务。
fn ordered_candidates(&self, now: Instant) -> Vec<Provider> {
let mut list: Vec<Provider> = [Provider::Google, Provider::Bing]
.into_iter()
.filter(|&p| self.is_available(p, now))
.collect();
if list.is_empty() {
// 若两个都在冷却期,允许全部重探
list.push(Provider::Bing);
list.push(Provider::Google);
} else if list.len() == 2 {
list.sort_by_key(|&p| self.stats[p.index()].ewma_ms);
}
list
}
fn record_success(&mut self, p: Provider, elapsed: Duration) {
let st = &mut self.stats[p.index()];
st.failures = 0;
st.retry_after = None;
let sample = elapsed.as_millis() as u64;
st.ewma_ms = (st.ewma_ms * 3 + sample) / 4;
}
fn record_failure(&mut self, p: Provider, now: Instant) {
let st = &mut self.stats[p.index()];
st.failures = st.failures.saturating_add(1);
let mult = 1 << st.failures.saturating_sub(1).min(3);
let cooldown = (BASE_COOLDOWN * mult).min(MAX_COOLDOWN);
st.retry_after = Some(now + cooldown);
st.ewma_ms = st.ewma_ms.saturating_add(1500);
}
}
static ROUTER: OnceLock<Mutex<RouterState>> = OnceLock::new();
fn router() -> &'static Mutex<RouterState> {
ROUTER.get_or_init(|| Mutex::new(RouterState::default()))
}
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let chunks = split_text(text);
if chunks.len() == 1 {
return translate_chunk_hedged(chunks[0].trim(), source, target).await;
}
// 多段长文本并发分块翻译,保留段间空白与换行
let futs = chunks.iter().map(|&chunk| async move {
let content = chunk.trim();
if content.is_empty() {
return Ok::<String, String>(chunk.to_string());
}
let translated = translate_chunk_hedged(content, source, target).await?;
let mut piece = String::new();
piece.push_str(&chunk[..chunk.len() - chunk.trim_start().len()]);
piece.push_str(translated.trim());
piece.push_str(&chunk[chunk.trim_end().len()..]);
Ok(piece)
});
let results = join_all(futs).await;
let mut output = String::new();
for res in results {
output.push_str(&res?);
}
Ok(output)
}
async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Result<String, String> {
let candidates = router()
.lock()
.map(|g| g.ordered_candidates(Instant::now()))
.unwrap_or_else(|_| vec![Provider::Bing, Provider::Google]);
let primary = candidates[0];
let secondary = candidates.get(1).copied().unwrap_or_else(|| primary.other());
let primary_fut = call_provider(primary, text, source, target);
tokio::pin!(primary_fut);
// 先给主服务 HEDGE_DELAY (550ms) 单独响应窗口
tokio::select! {
res = &mut primary_fut => {
match res {
Ok(out) => return Ok(out),
Err(_) => {
// 主服务立即报错,直接等待备用服务
return call_provider(secondary, text, source, target).await;
}
}
}
_ = tokio::time::sleep(HEDGE_DELAY) => {}
}
// 主服务超过 550ms 未返回:并发拉起备用服务竞速!
let secondary_fut = call_provider(secondary, text, source, target);
tokio::pin!(secondary_fut);
tokio::select! {
res1 = &mut primary_fut => {
match res1 {
Ok(out) => Ok(out),
Err(_) => secondary_fut.await,
}
}
res2 = &mut secondary_fut => {
match res2 {
Ok(out) => Ok(out),
Err(_) => primary_fut.await,
}
}
}
}
async fn call_provider(
provider: Provider,
text: &str,
source: &str,
target: &str,
) -> Result<String, String> {
let start = Instant::now();
let result = match provider {
Provider::Google => google::translate(text, source, target).await,
Provider::Bing => bing::translate(text, source, target).await,
};
if let Ok(mut guard) = router().lock() {
match &result {
Ok(_) => guard.record_success(provider, start.elapsed()),
Err(_) => guard.record_failure(provider, Instant::now()),
}
}
result
}
pub fn split_text(mut text: &str) -> Vec<&str> {
let mut chunks = Vec::new();
while !text.is_empty() {
let mut units = 0;
let mut end = text.len();
let mut boundary = None;
for (index, ch) in text.char_indices() {
units += ch.len_utf16();
if units > CHUNK_UTF16_LIMIT {
end = boundary.unwrap_or(index);
break;
}
if units >= CHUNK_UTF16_LIMIT / 2
&& (ch.is_whitespace() || matches!(ch, '.' | '!' | '?' | '。' | '!' | '?'))
{
boundary = Some(index + ch.len_utf8());
}
}
let (chunk, remaining) = text.split_at(end);
chunks.push(chunk);
text = remaining;
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn router_prefers_lower_latency_healthy_provider() {
let mut state = RouterState::default();
let now = Instant::now();
state.record_success(Provider::Bing, Duration::from_millis(120));
state.record_success(Provider::Google, Duration::from_millis(900));
assert_eq!(state.ordered_candidates(now)[0], Provider::Bing);
state.record_failure(Provider::Bing, now);
assert_eq!(state.ordered_candidates(now), vec![Provider::Google]);
}
#[test]
fn splits_long_text_without_losing_characters() {
let input = format!("{}\n{}", "测试文本 ".repeat(400), "hello world. ".repeat(300));
let chunks = split_text(&input);
assert!(chunks.len() > 1);
assert_eq!(chunks.concat(), input);
}
}
+229
View File
@@ -0,0 +1,229 @@
//! 必应翻译 —— 带有内存级 `BingToken` 缓存(默认 45 分钟复用,失效自动刷新)。
//!
//! 关键提速:
//! 旧版每次翻译都先请求并下载数 MB 的 `https://www.bing.com/translator` 网页解析 Token,
//! 导致必应翻译耗时翻倍且分块时重复抓网页。新版将 Token 缓存在内存中,后续请求全部单跳直达。
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::{
read_error_body, read_limited_response, shared_client, MAX_BING_PAGE_BYTES,
MAX_TRANSLATION_RESPONSE_BYTES,
};
const TRANSLATOR_URL: &str = "https://www.bing.com/translator";
const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
const TOKEN_TTL: Duration = Duration::from_secs(45 * 60);
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BingToken {
pub ig: String,
pub iid: String,
pub key: String,
pub token: String,
}
#[derive(Clone)]
struct CachedToken {
token: BingToken,
expires_at: Instant,
}
static TOKEN_CACHE: OnceLock<Mutex<Option<CachedToken>>> = OnceLock::new();
fn get_cached_token(now: Instant) -> Option<BingToken> {
let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None));
let mut guard = cache.lock().ok()?;
if let Some(entry) = guard.as_ref() {
if now < entry.expires_at {
return Some(entry.token.clone());
}
}
*guard = None;
None
}
fn store_cached_token(token: BingToken, now: Instant) {
let cache = TOKEN_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(mut guard) = cache.lock() {
*guard = Some(CachedToken {
token,
expires_at: now + TOKEN_TTL,
});
}
}
fn invalidate_cached_token() {
if let Some(cache) = TOKEN_CACHE.get() {
if let Ok(mut guard) = cache.lock() {
*guard = None;
}
}
}
fn map_lang(code: &str) -> &str {
match code {
"auto" => "auto-detect",
"zh-CN" => "zh-Hans",
"zh-TW" => "zh-Hant",
other => other,
}
}
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let client = shared_client(REQUEST_TIMEOUT, true);
let (token, from_cache) = match get_cached_token(Instant::now()) {
Some(t) => (t, true),
None => {
let fresh = fetch_token(&client).await?;
store_cached_token(fresh.clone(), Instant::now());
(fresh, false)
}
};
match translate_with_token(&client, &token, text, source, target).await {
Ok(out) => Ok(out),
Err(_first_err) if from_cache => {
// 缓存令牌可能提前失效,立即淘汰并刷新重试一次
invalidate_cached_token();
let fresh = fetch_token(&client).await?;
store_cached_token(fresh.clone(), Instant::now());
translate_with_token(&client, &fresh, text, source, target).await
}
Err(err) => Err(err),
}
}
async fn translate_with_token(
client: &reqwest::Client,
token: &BingToken,
text: &str,
source: &str,
target: &str,
) -> Result<String, String> {
let url = format!(
"{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}",
ig = urlencoding::encode(&token.ig),
iid = urlencoding::encode(&token.iid),
);
let resp = client
.post(&url)
.header("User-Agent", USER_AGENT)
.header("Referer", TRANSLATOR_URL)
.form(&[
("fromLang", map_lang(source)),
("to", map_lang(target)),
("text", text),
("token", &token.token),
("key", &token.key),
])
.send()
.await
.map_err(|e| format!("必应翻译请求失败: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = read_error_body(resp).await;
return Err(format!("必应翻译返回状态 {status}: {body}"));
}
let body =
read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "必应翻译响应").await?;
let body: Value =
serde_json::from_slice(&body).map_err(|e| format!("必应翻译响应解析失败: {e}"))?;
parse_response(&body)
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| "必应翻译未返回译文".to_string())
}
async fn fetch_token(client: &reqwest::Client) -> Result<BingToken, String> {
let response = client
.get(TRANSLATOR_URL)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| format!("必应翻译页请求失败: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = read_error_body(response).await;
return Err(format!("必应翻译页返回状态 {status}: {body}"));
}
let html = read_limited_response(response, MAX_BING_PAGE_BYTES, "必应翻译页").await?;
let html = std::str::from_utf8(&html).map_err(|e| format!("必应翻译页不是 UTF-8: {e}"))?;
parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())
}
pub fn parse_token(html: &str) -> Option<BingToken> {
let ig = extract_between(html, "IG:\"", "\"")?;
let iid = format!(
"translator.{}",
extract_between(html, "data-iid=\"translator.", "\"")?
);
let assignment = html
.split_once("params_AbusePreventionHelper")?
.1
.trim_start();
let assignment = assignment.strip_prefix('=')?.trim_start();
let helper = assignment.strip_prefix('[')?.split_once(']')?.0;
let parts: Vec<Value> = serde_json::from_str(&format!("[{helper}]")).ok()?;
let key = parts.first()?.as_u64()?.to_string();
let token = parts.get(1)?.as_str()?.to_string();
if key.is_empty() || token.is_empty() {
return None;
}
Some(BingToken {
ig,
iid,
key,
token,
})
}
fn extract_between(haystack: &str, start: &str, end: &str) -> Option<String> {
let begin = haystack.find(start)? + start.len();
let rest = &haystack[begin..];
let stop = rest.find(end)?;
Some(rest[..stop].to_string())
}
pub fn parse_response(body: &Value) -> Option<String> {
body.get(0)?
.get("translations")?
.get(0)?
.get("text")?
.as_str()
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bing_html_token_and_caches_with_ttl() {
let html = r#"IG:"FF00"; <div data-iid="translator.5028"></div>
params_AbusePreventionHelper = [167060,"abcdef",3600000];"#;
let tok = parse_token(html).unwrap();
assert_eq!(tok.ig, "FF00");
assert_eq!(tok.iid, "translator.5028");
assert_eq!(tok.token, "abcdef");
let now = Instant::now();
store_cached_token(tok.clone(), now);
assert_eq!(get_cached_token(now + Duration::from_secs(60)), Some(tok));
assert_eq!(get_cached_token(now + TOKEN_TTL + Duration::from_secs(1)), None);
}
}
+66
View File
@@ -0,0 +1,66 @@
//! 谷歌翻译 —— 改用 POST 表单请求避免长文本 URL 膨胀,并配合 1.8s 快速连接超时。
use std::time::Duration;
use serde_json::Value;
use crate::{read_error_body, read_limited_response, shared_client, MAX_TRANSLATION_RESPONSE_BYTES};
const ENDPOINT: &str = "https://translate.googleapis.com/translate_a/single";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let url = format!(
"{ENDPOINT}?client=gtx&sl={sl}&tl={tl}&dt=t",
sl = urlencoding::encode(source),
tl = urlencoding::encode(target),
);
let resp = shared_client(REQUEST_TIMEOUT, false)
.post(&url)
.header("User-Agent", "Mozilla/5.0")
.form(&[("q", text)])
.send()
.await
.map_err(|e| format!("谷歌翻译请求失败: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = read_error_body(resp).await;
return Err(format!("谷歌翻译返回状态 {status}: {body}"));
}
let body =
read_limited_response(resp, MAX_TRANSLATION_RESPONSE_BYTES, "谷歌翻译响应").await?;
let body: Value =
serde_json::from_slice(&body).map_err(|e| format!("谷歌翻译响应解析失败: {e}"))?;
let out = parse_response(&body);
if out.trim().is_empty() {
return Err("谷歌翻译未返回有效译文".to_string());
}
Ok(out)
}
pub fn parse_response(body: &Value) -> String {
let mut out = String::new();
if let Some(segments) = body.get(0).and_then(|v| v.as_array()) {
for seg in segments {
if let Some(t) = seg.get(0).and_then(|v| v.as_str()) {
out.push_str(t);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_multi_segment_google_response() {
let body = json!([[["你好", "hello"], ["世界", "world"]], null, "en"]);
assert_eq!(parse_response(&body), "你好世界");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! `ttree-translate` —— 极速网页翻译模块(Google POST + Bing 内存 Token 缓存 + Hedged 主备竞速路由)。
pub mod auto;
pub mod bing;
pub mod google;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::{Client, Response};
use ttree_core::{validate_language_pair, CancelToken};
pub const MAX_TRANSLATION_TEXT_BYTES: usize = 64 * 1024;
pub const MAX_TRANSLATION_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
pub const MAX_BING_PAGE_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_ERROR_BODY_BYTES: usize = 16 * 1024;
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1800);
/// 共享连接池客户端:内置 1.8s 快速连接超时与 TCP Keepalive,彻底消除不可达节点卡死 15s 的问题。
pub(crate) fn shared_client(timeout: Duration, cookie_store: bool) -> Client {
static CLIENTS: OnceLock<Mutex<HashMap<(Duration, bool), Client>>> = OnceLock::new();
let clients = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
let mut clients = clients.lock().expect("HTTP client cache lock poisoned");
clients
.entry((timeout, cookie_store))
.or_insert_with(|| {
Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(timeout)
.tcp_keepalive(Duration::from_secs(30))
.pool_idle_timeout(Duration::from_secs(90))
.cookie_store(cookie_store)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build reqwest client")
})
.clone()
}
pub fn validate_translation_text(text: &str) -> Result<(), String> {
if text.len() > MAX_TRANSLATION_TEXT_BYTES {
return Err(format!(
"待翻译文本超出 {MAX_TRANSLATION_TEXT_BYTES} 字节上限"
));
}
Ok(())
}
/// 顶层网页翻译入口(支持 `CancelToken` 即时中断与主备竞速)。
pub async fn translate_web(
text: &str,
source: &str,
target: &str,
cancel: &CancelToken,
) -> Result<String, String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
validate_translation_text(trimmed)?;
validate_language_pair(source, target)?;
tokio::select! {
biased;
_ = cancel.cancelled() => Err("翻译已取消".to_string()),
res = auto::translate(trimmed, source, target) => res,
}
}
pub(crate) async fn read_limited_response(
response: Response,
limit: usize,
label: &str,
) -> Result<Vec<u8>, String> {
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(format!("{label}超出 {limit} 字节上限"));
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取{label}失败: {e}"))?;
if bytes.len().saturating_add(chunk.len()) > limit {
return Err(format!("{label}超出 {limit} 字节上限"));
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
pub(crate) async fn read_error_body(response: Response) -> String {
let mut bytes = Vec::new();
let mut truncated = false;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(chunk) => {
let remaining = MAX_ERROR_BODY_BYTES.saturating_sub(bytes.len());
if chunk.len() > remaining {
bytes.extend_from_slice(&chunk[..remaining]);
truncated = true;
break;
}
bytes.extend_from_slice(&chunk);
}
Err(e) => return format!("<读取错误响应失败: {e}>"),
}
}
let mut out = String::from_utf8_lossy(&bytes).into_owned();
if truncated {
out.push_str("…[truncated]");
}
out
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.cargo/bin:$PATH"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_NAME="TTREE"
BUNDLE_IDENTIFIER="com.samwstu.ttree"
PINNED_SIGNING_IDENTITY="TTREE Open Source Code Signing"
PINNED_CERT_SHA1="B8D254561BB6CAD78AA71FA18D62A32D10F50C08"
VERSION="0.2.0"
BUILD_NUMBER="${1:-1}"
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}"
BUNDLE_DIR="$TARGET_DIR/release/bundle/macos"
APP_PATH="$BUNDLE_DIR/$APP_NAME.app"
CONTENTS_DIR="$APP_PATH/Contents"
MACOS_BIN_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
echo "[1/4] Compiling 100% pure-Rust TTREE release binary..."
cd "$ROOT_DIR"
cargo build --release -p ttree-app
echo "[2/4] Assembling macOS App Bundle at $APP_PATH..."
rm -rf "$APP_PATH"
mkdir -p "$MACOS_BIN_DIR" "$RESOURCES_DIR"
cp "$TARGET_DIR/release/ttree" "$MACOS_BIN_DIR/ttree"
chmod +x "$MACOS_BIN_DIR/ttree"
if [[ -f "$ROOT_DIR/src-tauri/icons/icon.icns" ]]; then
cp "$ROOT_DIR/src-tauri/icons/icon.icns" "$RESOURCES_DIR/icon.icns"
fi
cat > "$CONTENTS_DIR/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleExecutable</key>
<string>ttree</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${BUILD_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSScreenCaptureUsageDescription</key>
<string>TTREE 需要屏幕录制权限以完成自由框选截图 OCR 翻译。</string>
</dict>
</plist>
EOF
echo "[3/4] Signing $APP_NAME.app with pinned community certificate ($PINNED_CERT_SHA1)..."
TEMP_KEYCHAIN=""
cleanup() {
if [[ -n "$TEMP_KEYCHAIN" && -f "$TEMP_KEYCHAIN" ]]; then
security delete-keychain "$TEMP_KEYCHAIN" >/dev/null 2>&1 || rm -f "$TEMP_KEYCHAIN"
fi
}
trap cleanup EXIT
SIGNING_KEYCHAIN="${MACOS_COMMUNITY_SIGNING_KEYCHAIN:-}"
if [[ -z "$SIGNING_KEYCHAIN" ]]; then
P12_PATH="$HOME/.config/ttree-code-signing/community-signing.p12"
if [[ -f "$P12_PATH" ]]; then
P12_PASS="$(security find-generic-password -s "com.samwstu.ttree" -a "community-code-signing-password" -w)"
TEMP_KEYCHAIN="$(mktemp -u /tmp/ttree-signing.XXXXXX).keychain-db"
KC_PASS="$(uuidgen)"
security create-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
security set-keychain-settings -lut 300 "$TEMP_KEYCHAIN"
security unlock-keychain -p "$KC_PASS" "$TEMP_KEYCHAIN"
security import "$P12_PATH" -k "$TEMP_KEYCHAIN" -P "$P12_PASS" -T /usr/bin/codesign -T /usr/bin/security >/dev/null
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$TEMP_KEYCHAIN" >/dev/null
SIGNING_KEYCHAIN="$TEMP_KEYCHAIN"
fi
fi
REQUIREMENT="=designated => identifier \"$BUNDLE_IDENTIFIER\" and certificate leaf = H\"$PINNED_CERT_SHA1\""
CODESIGN_ARGS=(
--force
--options runtime
--timestamp=none
--sign "$PINNED_CERT_SHA1"
--identifier "$BUNDLE_IDENTIFIER"
--requirements "$REQUIREMENT"
)
if [[ -n "$SIGNING_KEYCHAIN" ]]; then
CODESIGN_ARGS+=(--keychain "$SIGNING_KEYCHAIN")
fi
/usr/bin/codesign "${CODESIGN_ARGS[@]}" "$APP_PATH"
echo "[4/4] Verifying macOS community signature & TCC designated requirement..."
"$ROOT_DIR/scripts/verify-macos-community-signature.sh" "$APP_PATH"
printf '\nPure-Rust TTREE.app packaged and verified at: %s\n' "$APP_PATH"