diff --git a/Cargo.lock b/Cargo.lock index 536f33a..186fd7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3251,7 +3251,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttree-ai" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "candle-core", @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "ttree-core" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -3279,7 +3279,7 @@ dependencies = [ [[package]] name = "ttree-macos" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "global-hotkey", @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "ttree-ocr" -version = "0.2.1" +version = "0.2.2" dependencies = [ "objc2", "objc2-core-foundation", @@ -3317,7 +3317,7 @@ dependencies = [ [[package]] name = "ttree-platform" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "auto-launch", @@ -3332,7 +3332,7 @@ dependencies = [ [[package]] name = "ttree-storage" -version = "0.2.1" +version = "0.2.2" dependencies = [ "dirs 6.0.0", "serde", @@ -3343,7 +3343,7 @@ dependencies = [ [[package]] name = "ttree-translate" -version = "0.2.1" +version = "0.2.2" dependencies = [ "futures-util", "reqwest", @@ -3356,7 +3356,7 @@ dependencies = [ [[package]] name = "ttree-updater" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -3377,7 +3377,7 @@ dependencies = [ [[package]] name = "ttree-windows" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "global-hotkey", diff --git a/Cargo.toml b/Cargo.toml index ed8d30c..c036ad5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2021" authors = ["ZASENJC"] license = "MIT" @@ -114,8 +114,9 @@ objc2-app-kit = { version = "0.3", features = [ "NSVisualEffectView", "NSWindow", "objc2-core-foundation", + "objc2-core-graphics", ] } -objc2-quartz-core = { version = "0.3", features = ["CALayer"] } +objc2-quartz-core = { version = "0.3", features = ["CALayer", "objc2-core-graphics"] } windows = { version = "0.58", features = [ "Foundation", "Foundation_Collections", diff --git a/apps/macos/src/ui_helpers.rs b/apps/macos/src/ui_helpers.rs index d6620b5..2b1ab84 100644 --- a/apps/macos/src/ui_helpers.rs +++ b/apps/macos/src/ui_helpers.rs @@ -280,8 +280,8 @@ pub fn make_button( if let Some(layer) = btn.layer() { layer.setCornerRadius(6.0); let bg = pale_ink(0.05); - let cg_bg: *const AnyObject = msg_send![&*bg, CGColor]; - let _: () = msg_send![&*layer, setBackgroundColor: cg_bg]; + let cg_bg = bg.CGColor(); + layer.setBackgroundColor(Some(&cg_bg)); } btn.setTag(tag); btn.setTarget(Some(target)); diff --git a/crates/ttree-translate/src/auto.rs b/crates/ttree-translate/src/auto.rs index a9f2b6d..b9cc765 100644 --- a/crates/ttree-translate/src/auto.rs +++ b/crates/ttree-translate/src/auto.rs @@ -37,6 +37,26 @@ impl Provider { Self::Bing => Self::Google, } } + + fn label(self) -> &'static str { + match self { + Self::Google => "谷歌", + Self::Bing => "必应", + } + } +} + +fn combine_provider_errors( + primary: Provider, + primary_err: String, + secondary: Provider, + secondary_err: String, +) -> String { + format!( + "{}: {primary_err} | {}: {secondary_err}", + primary.label(), + secondary.label() + ) } #[derive(Clone, Copy)] @@ -154,9 +174,13 @@ async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Resul res = &mut primary_fut => { match res { Ok(out) => return Ok(out), - Err(_) => { + Err(primary_err) => { // 主服务立即报错,直接等待备用服务 - return call_provider(secondary, text, source, target).await; + return call_provider(secondary, text, source, target) + .await + .map_err(|secondary_err| { + combine_provider_errors(primary, primary_err, secondary, secondary_err) + }); } } } @@ -171,13 +195,21 @@ async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Resul res1 = &mut primary_fut => { match res1 { Ok(out) => Ok(out), - Err(_) => secondary_fut.await, + Err(primary_err) => secondary_fut + .await + .map_err(|secondary_err| { + combine_provider_errors(primary, primary_err, secondary, secondary_err) + }), } } res2 = &mut secondary_fut => { match res2 { Ok(out) => Ok(out), - Err(_) => primary_fut.await, + Err(secondary_err) => primary_fut + .await + .map_err(|primary_err| { + combine_provider_errors(primary, primary_err, secondary, secondary_err) + }), } } } @@ -255,4 +287,18 @@ mod tests { assert!(chunks.len() > 1); assert_eq!(chunks.concat(), input); } + + #[test] + fn combines_primary_and_secondary_errors_when_both_fail() { + let msg = combine_provider_errors( + Provider::Bing, + "必应翻译页返回状态 302 Found".into(), + Provider::Google, + "谷歌翻译返回状态 429 Too Many Requests".into(), + ); + assert_eq!( + msg, + "必应: 必应翻译页返回状态 302 Found | 谷歌: 谷歌翻译返回状态 429 Too Many Requests" + ); + } } diff --git a/crates/ttree-translate/src/bing.rs b/crates/ttree-translate/src/bing.rs index ca43363..a7cb019 100644 --- a/crates/ttree-translate/src/bing.rs +++ b/crates/ttree-translate/src/bing.rs @@ -14,8 +14,8 @@ use crate::{ MAX_TRANSLATION_RESPONSE_BYTES, }; +const DEFAULT_ORIGIN: &str = "https://www.bing.com"; 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 \ @@ -27,6 +27,7 @@ pub struct BingToken { pub iid: String, pub key: String, pub token: String, + pub origin: String, } #[derive(Clone)] @@ -76,13 +77,30 @@ fn map_lang(code: &str) -> &str { } } +fn is_allowed_bing_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "bing.com" || host.ends_with(".bing.com") +} + +fn origin_from_url(url: &reqwest::Url) -> Option { + if url.scheme() != "https" && url.scheme() != "http" { + return None; + } + let host = url.host_str()?; + let mut origin = format!("{}://{}", url.scheme(), host); + if let Some(port) = url.port() { + origin.push_str(&format!(":{port}")); + } + Some(origin) +} + pub async fn translate(text: &str, source: &str, target: &str) -> Result { 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?; + let fresh = fetch_token(&client, TRANSLATOR_URL).await?; store_cached_token(fresh.clone(), Instant::now()); (fresh, false) } @@ -93,7 +111,7 @@ pub async fn translate(text: &str, source: &str, target: &str) -> Result { // 缓存令牌可能提前失效,立即淘汰并刷新重试一次 invalidate_cached_token(); - let fresh = fetch_token(&client).await?; + let fresh = fetch_token(&client, TRANSLATOR_URL).await?; store_cached_token(fresh.clone(), Instant::now()); translate_with_token(&client, &fresh, text, source, target).await } @@ -108,26 +126,46 @@ async fn translate_with_token( source: &str, target: &str, ) -> Result { - let url = format!( - "{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}", + let origin = if token.origin.is_empty() { + DEFAULT_ORIGIN + } else { + token.origin.as_str() + }; + let referer = format!("{origin}/translator"); + let mut url = format!( + "{origin}/ttranslatev3?isVertical=1&IG={ig}&IID={iid}", ig = urlencoding::encode(&token.ig), iid = urlencoding::encode(&token.iid), ); + let form = [ + ("fromLang", map_lang(source)), + ("to", map_lang(target)), + ("text", text), + ("token", &token.token), + ("key", &token.key), + ]; - 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}"))?; + let mut resp = None; + for _ in 0..=3 { + let current = client + .post(&url) + .header("User-Agent", USER_AGENT) + .header("Referer", &referer) + .form(&form) + .send() + .await + .map_err(|e| format!("必应翻译请求失败: {e}"))?; + + if current.status().is_redirection() { + if let Some(next_url) = resolve_redirect_url(&url, current.headers()) { + url = next_url; + continue; + } + } + resp = Some(current); + break; + } + let resp = resp.ok_or_else(|| "必应翻译重定向次数过多".to_string())?; if !resp.status().is_success() { let status = resp.status(); @@ -144,22 +182,52 @@ async fn translate_with_token( .ok_or_else(|| "必应翻译未返回译文".to_string()) } -async fn fetch_token(client: &reqwest::Client) -> Result { - let response = client - .get(TRANSLATOR_URL) - .header("User-Agent", USER_AGENT) - .send() - .await - .map_err(|e| format!("必应翻译页请求失败: {e}"))?; +fn resolve_redirect_url(current_url: &str, headers: &reqwest::header::HeaderMap) -> Option { + let loc = headers.get(reqwest::header::LOCATION)?.to_str().ok()?; + let base = reqwest::Url::parse(current_url).ok()?; + let target = base.join(loc).ok()?; + let host = target.host_str()?; + let same_loopback = base.host_str() == Some(host) && (host == "127.0.0.1" || host == "localhost"); + if (target.scheme() == "https" && is_allowed_bing_host(host)) || same_loopback { + Some(target.into()) + } else { + None + } +} + +async fn fetch_token(client: &reqwest::Client, translator_url: &str) -> Result { + let mut url = translator_url.to_string(); + let mut response = None; + for _ in 0..=3 { + let current = client + .get(&url) + .header("User-Agent", USER_AGENT) + .send() + .await + .map_err(|e| format!("必应翻译页请求失败: {e}"))?; + if current.status().is_redirection() { + if let Some(next_url) = resolve_redirect_url(&url, current.headers()) { + url = next_url; + continue; + } + } + response = Some(current); + break; + } + let response = response.ok_or_else(|| "必应翻译页重定向次数过多".to_string())?; if !response.status().is_success() { let status = response.status(); let body = read_error_body(response).await; return Err(format!("必应翻译页返回状态 {status}: {body}")); } + let final_origin = origin_from_url(response.url()).unwrap_or_else(|| DEFAULT_ORIGIN.to_string()); 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()) + let mut token = + parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())?; + token.origin = final_origin; + Ok(token) } pub fn parse_token(html: &str) -> Option { @@ -188,6 +256,7 @@ pub fn parse_token(html: &str) -> Option { iid, key, token, + origin: DEFAULT_ORIGIN.to_string(), }) } @@ -210,6 +279,8 @@ pub fn parse_response(body: &Value) -> Option { #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; #[test] fn parses_bing_html_token_and_caches_with_ttl() { @@ -228,4 +299,76 @@ mod tests { None ); } + + #[tokio::test] + async fn follows_302_redirects_for_token_and_translation_post() { + let redirect_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let redirect_addr = redirect_listener.local_addr().unwrap(); + let target_addr = target_listener.local_addr().unwrap(); + + let redirect_srv = tokio::spawn(async move { + let (mut socket, _) = redirect_listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await.unwrap(); + let resp = format!( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/translator-cn\r\nSet-Cookie: SNRHOP=1; Path=/\r\nContent-Length: 0\r\n\r\n", + target_addr.port() + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + }); + + let target_srv = tokio::spawn(async move { + for step in 0..3 { + let (mut socket, _) = target_listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let n = socket.read(&mut buf).await.unwrap(); + let req = String::from_utf8_lossy(&buf[..n]); + match step { + 0 => { + assert!(req.starts_with("GET /translator-cn "), "{req}"); + let body = r#"IG:"FF00";
params_AbusePreventionHelper = [167060,"abcdef",3600000];"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + } + 1 => { + assert!(req.starts_with("POST /ttranslatev3?isVertical=1&IG=FF00&IID=translator.5028 "), "{req}"); + let resp = format!( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/ttranslatev3-cn?isVertical=1&IG=FF00&IID=translator.5028\r\nContent-Length: 0\r\n\r\n", + target_addr.port() + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + } + _ => { + assert!(req.starts_with("POST /ttranslatev3-cn?isVertical=1&IG=FF00&IID=translator.5028 "), "{req}"); + assert!(req.contains("text=hello"), "{req}"); + let body = r#"[{"translations":[{"text":"你好","to":"zh-Hans"}]}]"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + } + } + } + }); + + let client = shared_client(Duration::from_secs(5), true); + let initial_url = format!("http://127.0.0.1:{}/translator", redirect_addr.port()); + let token = fetch_token(&client, &initial_url).await.unwrap(); + assert_eq!(token.origin, format!("http://127.0.0.1:{}", target_addr.port())); + + let translated = translate_with_token(&client, &token, "hello", "en", "zh-CN") + .await + .unwrap(); + assert_eq!(translated, "你好"); + + redirect_srv.await.unwrap(); + target_srv.await.unwrap(); + } }