diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59121a5..440ca30 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -615,6 +615,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -860,12 +866,14 @@ dependencies = [ "flate2", "futures", "futures-util", + "image", "keyring", "kill_tree", "mac-notification-sys", "notify", "portable-pty", "prost", + "qrcode", "rand 0.8.5", "regex", "reqwest 0.12.28", @@ -2465,7 +2473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2576,6 +2584,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3196,6 +3217,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -3211,7 +3242,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -4119,6 +4150,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4340,6 +4384,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -6263,7 +6322,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -7014,7 +7073,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 089dccd..d02625f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,6 +82,8 @@ tokio-tungstenite = { version = "0.26", features = ["native-tls"] } futures-util = "0.3" prost = "0.13" rand = "0.8" +qrcode = "0.14" +image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = { version = "2", optional = true } diff --git a/src-tauri/src/chat_channel/backends/weixin.rs b/src-tauri/src/chat_channel/backends/weixin.rs index fcbcce6..2177e74 100644 --- a/src-tauri/src/chat_channel/backends/weixin.rs +++ b/src-tauri/src/chat_channel/backends/weixin.rs @@ -13,6 +13,24 @@ use crate::chat_channel::traits::ChatChannelBackend; use crate::chat_channel::types::*; const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; +const ILINK_CHANNEL_VERSION: &str = "1.0.2"; +/// Maximum number of messages buffered while context_token is expired. +const MAX_PENDING_MESSAGES: usize = 50; + +/// Shared HTTP client for QR code auth requests (avoids re-creating TLS state). +fn qr_client() -> reqwest::Client { + use std::sync::OnceLock; + static CLIENT: OnceLock = OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)) + .build() + .unwrap_or_default() + }) + .clone() +} // ── QR code auth types (public, used by commands) ── @@ -25,16 +43,24 @@ pub struct WeixinQrcodeInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WeixinQrcodeStatus { pub status: String, + /// bot_token and base_url are consumed by the _core command layer and + /// stripped before the response reaches the frontend. #[serde(skip_serializing_if = "Option::is_none")] pub bot_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } +/// Frontend-safe subset of [`WeixinQrcodeStatus`] — no credentials. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeixinQrcodeStatusPublic { + pub status: String, +} + // ── QR code auth functions (called before backend exists) ── pub async fn weixin_get_qrcode() -> Result { - let client = reqwest::Client::new(); + let client = qr_client(); let resp = client .get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode")) .query(&[("bot_type", "3")]) @@ -64,14 +90,15 @@ pub async fn weixin_get_qrcode() -> Result { )); } - // If the image content is a URL, fetch the actual image bytes and - // convert to a data-URI so the frontend can display it without CORS issues. + // If the image content is a URL, try to fetch the actual image bytes. + // If the URL points to an HTML SPA (which renders the QR code via JS), + // generate the QR code ourselves — the SPA simply encodes the page URL. let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") { match fetch_image_as_data_uri(&client, &raw_img).await { Ok(data_uri) => data_uri, - Err(e) => { - eprintln!("[Weixin] failed to proxy QR image: {e}, falling back to URL"); - raw_img + Err(_) => { + eprintln!("[Weixin] URL is an SPA page, generating QR code from URL"); + generate_qrcode_data_uri(&raw_img)? } } } else { @@ -85,6 +112,9 @@ pub async fn weixin_get_qrcode() -> Result { } /// Fetch an image from a URL and return it as a `data:;base64,...` string. +/// +/// Returns an error if the URL points to an HTML page (SPA) rather than a +/// raw image — the caller will generate a QR code from the URL instead. async fn fetch_image_as_data_uri( client: &reqwest::Client, url: &str, @@ -107,11 +137,10 @@ async fn fetch_image_as_data_uri( .unwrap_or("image/png") .to_string(); - // If the server returned HTML instead of an image, bail out if content_type.contains("text/html") || content_type.contains("text/plain") { - return Err(ChatChannelError::ConnectionFailed(format!( - "Expected image but got {content_type}" - ))); + return Err(ChatChannelError::ConnectionFailed( + "QR code URL is an SPA page".into(), + )); } let bytes = resp @@ -119,16 +148,48 @@ async fn fetch_image_as_data_uri( .await .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?; + if bytes.is_empty() { + return Err(ChatChannelError::ConnectionFailed( + "Empty image response".into(), + )); + } let b64 = B64.encode(&bytes); - // Normalize content-type: strip parameters like charset let mime = content_type.split(';').next().unwrap_or("image/png").trim(); Ok(format!("data:{mime};base64,{b64}")) } +/// Generate a QR code image encoding the given text and return as a PNG data URI. +/// +/// The iLink QR page is a SPA that renders `window.location.href` as a QR code. +/// We replicate that logic server-side so the frontend can display it directly. +fn generate_qrcode_data_uri(content: &str) -> Result { + use image::{codecs::png::PngEncoder, ImageEncoder, Luma}; + use qrcode::QrCode; + + let code = QrCode::new(content.as_bytes()).map_err(|e| { + ChatChannelError::ConnectionFailed(format!("QR code generation failed: {e}")) + })?; + + let img = code + .render::>() + .quiet_zone(true) + .min_dimensions(250, 250) + .build(); + let (w, h) = (img.width(), img.height()); + + let mut png_buf: Vec = Vec::new(); + PngEncoder::new(&mut png_buf) + .write_image(img.as_raw(), w, h, image::ExtendedColorType::L8) + .map_err(|e| ChatChannelError::ConnectionFailed(format!("PNG encoding failed: {e}")))?; + + let b64 = B64.encode(&png_buf); + Ok(format!("data:image/png;base64,{b64}")) +} + pub async fn weixin_check_qrcode( qrcode: &str, ) -> Result { - let client = reqwest::Client::new(); + let client = qr_client(); let resp = client .get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status")) .query(&[("qrcode", qrcode)]) @@ -181,10 +242,15 @@ pub struct WeixinBackend { reply_context: Arc>>, /// Messages that failed due to expired context_token, resend on next refresh. pending_messages: Arc>>, + /// Stable X-WECHAT-UIN value for this backend instance. + wechat_uin: String, } impl WeixinBackend { pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self { + let uin_raw = rand::thread_rng().gen::().to_string(); + let wechat_uin = B64.encode(uin_raw.as_bytes()); + Self { bot_token, base_url, @@ -198,10 +264,11 @@ impl WeixinBackend { shutdown_tx: Arc::new(Mutex::new(None)), reply_context: Arc::new(Mutex::new(None)), pending_messages: Arc::new(Mutex::new(Vec::new())), + wechat_uin, } } - fn build_headers(bot_token: &str) -> HeaderMap { + fn build_headers(bot_token: &str, wechat_uin: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert("Content-Type", HeaderValue::from_static("application/json")); headers.insert( @@ -209,9 +276,7 @@ impl WeixinBackend { HeaderValue::from_static("ilink_bot_token"), ); - let uin_raw = rand::thread_rng().gen::().to_string(); - let uin_b64 = B64.encode(uin_raw.as_bytes()); - if let Ok(val) = HeaderValue::from_str(&uin_b64) { + if let Ok(val) = HeaderValue::from_str(wechat_uin) { headers.insert("X-WECHAT-UIN", val); } @@ -223,6 +288,95 @@ impl WeixinBackend { headers } + /// Build the JSON body for the iLink sendmessage API. + fn build_send_body( + to_user_id: &str, + context_token: &str, + text: &str, + ) -> serde_json::Value { + serde_json::json!({ + "msg": { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": format!("codeg-{}", uuid::Uuid::new_v4()), + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{ + "type": 1, + "text_item": { "text": text } + }] + }, + "base_info": { "channel_version": ILINK_CHANNEL_VERSION } + }) + } + + /// Send a message via the iLink API and handle the response. + /// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context. + async fn do_send( + client: &reqwest::Client, + base_url: &str, + bot_token: &str, + wechat_uin: &str, + to_user_id: &str, + context_token: &str, + text: &str, + reply_context: &Mutex>, + pending_messages: &Mutex>, + ) -> Result { + let body = Self::build_send_body(to_user_id, context_token, text); + let url = format!("{base_url}/ilink/bot/sendmessage"); + + let resp = client + .post(&url) + .headers(Self::build_headers(bot_token, wechat_uin)) + .json(&body) + .send() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + let status_code = resp.status(); + let resp_text = resp.text().await.unwrap_or_default(); + + if !status_code.is_success() { + return Err(ChatChannelError::SendFailed(format!( + "HTTP {status_code}: {resp_text}" + ))); + } + + // Check for ret errors in response (e.g. -2 = context expired) + if let Ok(resp_json) = serde_json::from_str::(&resp_text) { + if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { + if ret != 0 { + let errmsg = resp_json + .get("errmsg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}"); + + if ret == -2 { + // Context token expired — mark stale and buffer + if let Some(ref mut c) = *reply_context.lock().await { + c.expired = true; + } + let mut buf = pending_messages.lock().await; + if buf.len() < MAX_PENDING_MESSAGES { + buf.push(text.to_string()); + } + eprintln!("[Weixin] context_token expired (ret=-2), buffered message"); + return Ok(false); + } + + return Err(ChatChannelError::SendFailed(format!( + "ret={ret}: {errmsg}" + ))); + } + } + } + + Ok(true) + } + async fn send_text( &self, text: &str, @@ -249,79 +403,33 @@ impl WeixinBackend { "[Weixin] context expired, buffering message (len={})", text.len() ); - self.pending_messages.lock().await.push(text.to_string()); + let mut buf = self.pending_messages.lock().await; + if buf.len() < MAX_PENDING_MESSAGES { + buf.push(text.to_string()); + } else { + eprintln!("[Weixin] pending buffer full, dropping message"); + } return Ok(SentMessageId(String::new())); } - let client_id = format!("codeg-{}", uuid::Uuid::new_v4()); - let body = serde_json::json!({ - "msg": { - "from_user_id": "", - "to_user_id": to_user_id, - "client_id": client_id, - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{ - "type": 1, - "text_item": { "text": text } - }] - }, - "base_info": { "channel_version": "1.0.2" } - }); - - let url = format!("{}/ilink/bot/sendmessage", self.base_url); eprintln!( "[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}", context_token.len(), text.len() ); - let resp = self - .client - .post(&url) - .headers(Self::build_headers(&self.bot_token)) - .json(&body) - .send() - .await - .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; - - let status_code = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - eprintln!("[Weixin] sendmessage response: status={status_code}, body={resp_text}"); - - if !status_code.is_success() { - return Err(ChatChannelError::SendFailed(format!( - "HTTP {status_code}: {resp_text}" - ))); - } - - // Check for ret errors in response (e.g. -2 = context expired) - if let Ok(resp_json) = serde_json::from_str::(&resp_text) { - if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { - if ret != 0 { - let errmsg = resp_json - .get("errmsg") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}"); - - if ret == -2 { - // Context token expired — mark stale and buffer - if let Some(ref mut c) = *self.reply_context.lock().await { - c.expired = true; - } - self.pending_messages.lock().await.push(text.to_string()); - eprintln!("[Weixin] context_token expired (ret=-2), buffered message"); - return Ok(SentMessageId(String::new())); - } - - return Err(ChatChannelError::SendFailed(format!( - "ret={ret}: {errmsg}" - ))); - } - } - } + Self::do_send( + &self.client, + &self.base_url, + &self.bot_token, + &self.wechat_uin, + &to_user_id, + &context_token, + text, + &self.reply_context, + &self.pending_messages, + ) + .await?; Ok(SentMessageId(String::new())) } @@ -348,7 +456,7 @@ impl ChatChannelBackend for WeixinBackend { // Verify auth by doing a quick getupdates with empty cursor let verify_body = serde_json::json!({ "get_updates_buf": "", - "base_info": { "channel_version": "1.0.2" } + "base_info": { "channel_version": ILINK_CHANNEL_VERSION } }); let url = format!("{}/ilink/bot/getupdates", self.base_url); eprintln!("[Weixin] verify POST {url}"); @@ -356,7 +464,7 @@ impl ChatChannelBackend for WeixinBackend { let resp = self .client .post(&url) - .headers(Self::build_headers(&self.bot_token)) + .headers(Self::build_headers(&self.bot_token, &self.wechat_uin)) .json(&verify_body) .send() .await @@ -380,6 +488,13 @@ impl ChatChannelBackend for WeixinBackend { .and_then(|v| v.as_i64()) .unwrap_or(-1); + // Check for known auth-failure codes + if ret == -14 { + return Err(ChatChannelError::AuthenticationFailed( + "Session expired (ret=-14), please re-authenticate".into(), + )); + } + // The iLink API may omit the `ret` field or return non-zero on the first // call. Always extract the cursor if present — it's needed for polling. let initial_cursor = verify_result @@ -404,6 +519,7 @@ impl ChatChannelBackend for WeixinBackend { let client = self.client.clone(); let bot_token = self.bot_token.clone(); let base_url = self.base_url.clone(); + let wechat_uin = self.wechat_uin.clone(); let channel_id = self.channel_id; let status = self.status.clone(); let reply_context = self.reply_context.clone(); @@ -420,13 +536,13 @@ impl ChatChannelBackend for WeixinBackend { let body = serde_json::json!({ "get_updates_buf": cursor, - "base_info": { "channel_version": "1.0.2" } + "base_info": { "channel_version": ILINK_CHANNEL_VERSION } }); let result = tokio::select! { r = client .post(format!("{base_url}/ilink/bot/getupdates")) - .headers(WeixinBackend::build_headers(&bot_token)) + .headers(WeixinBackend::build_headers(&bot_token, &wechat_uin)) .json(&body) .send() => r, _ = shutdown_rx.changed() => break, @@ -521,19 +637,21 @@ impl ChatChannelBackend for WeixinBackend { .unwrap_or_default(); // Store reply context for outbound messages + // Single lock scope to avoid TOCTOU if !from_user_id.is_empty() && !context_token.is_empty() { - let was_expired = reply_context - .lock() - .await - .as_ref() - .map(|c| c.expired) - .unwrap_or(false); - - *reply_context.lock().await = Some(WeixinReplyContext { - to_user_id: from_user_id.to_string(), - context_token: context_token.to_string(), - expired: false, - }); + let was_expired = { + let mut guard = reply_context.lock().await; + let was = guard + .as_ref() + .map(|c| c.expired) + .unwrap_or(false); + *guard = Some(WeixinReplyContext { + to_user_id: from_user_id.to_string(), + context_token: context_token.to_string(), + expired: false, + }); + was + }; // Resend buffered messages with fresh context if was_expired { @@ -545,52 +663,29 @@ impl ChatChannelBackend for WeixinBackend { buffered.len() ); for pending_text in &buffered { - let cid = - format!("codeg-{}", uuid::Uuid::new_v4()); - let send_body = serde_json::json!({ - "msg": { - "from_user_id": "", - "to_user_id": from_user_id, - "client_id": cid, - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{ - "type": 1, - "text_item": { "text": pending_text } - }] - }, - "base_info": { "channel_version": "1.0.2" } - }); - let send_ok = match client - .post(format!( - "{base_url}/ilink/bot/sendmessage" - )) - .headers(WeixinBackend::build_headers( - &bot_token, - )) - .json(&send_body) - .send() - .await - { - Ok(r) => { - let ok = r.status().is_success(); - if !ok { - eprintln!("[Weixin] resend failed: HTTP {}", r.status()); - } - ok + let ok = WeixinBackend::do_send( + &client, + &base_url, + &bot_token, + &wechat_uin, + from_user_id, + context_token, + pending_text, + &reply_context, + &pending_messages, + ) + .await; + if let Err(e) = ok { + eprintln!("[Weixin] resend error: {e}"); + // Re-buffer remaining on hard error + let mut buf = + pending_messages.lock().await; + if buf.len() < MAX_PENDING_MESSAGES { + buf.push(pending_text.clone()); } - Err(e) => { - eprintln!("[Weixin] resend error: {e}"); - false - } - }; - if !send_ok { - // Re-buffer remaining messages - pending_messages.lock().await.push( - pending_text.clone(), - ); } + // If do_send returned Ok(false), it + // already re-buffered internally. } } } @@ -662,14 +757,14 @@ impl ChatChannelBackend for WeixinBackend { async fn test_connection(&self) -> Result<(), ChatChannelError> { let body = serde_json::json!({ "get_updates_buf": "", - "base_info": { "channel_version": "1.0.2" } + "base_info": { "channel_version": ILINK_CHANNEL_VERSION } }); let url = format!("{}/ilink/bot/getupdates", self.base_url); let resp = self .client .post(&url) - .headers(Self::build_headers(&self.bot_token)) + .headers(Self::build_headers(&self.bot_token, &self.wechat_uin)) .json(&body) .send() .await @@ -683,9 +778,7 @@ impl ChatChannelBackend for WeixinBackend { eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}"); - // As long as we got a valid JSON response from the server, treat it as reachable. - // The iLink API may return ret != 0 on first empty-cursor call. - let _: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| { + let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| { ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")) })?; @@ -695,6 +788,15 @@ impl ChatChannelBackend for WeixinBackend { ))); } + // Check for known auth-failure codes + if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { + if ret == -14 { + return Err(ChatChannelError::AuthenticationFailed( + "Session expired (ret=-14)".into(), + )); + } + } + Ok(()) } } diff --git a/src-tauri/src/commands/chat_channel.rs b/src-tauri/src/commands/chat_channel.rs index 1306daa..bbb4914 100644 --- a/src-tauri/src/commands/chat_channel.rs +++ b/src-tauri/src/commands/chat_channel.rs @@ -1,5 +1,5 @@ use crate::app_error::AppCommandError; -use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; +use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic}; use crate::chat_channel::manager::ChatChannelManager; use crate::chat_channel::types::ChannelType; use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; @@ -351,7 +351,7 @@ pub async fn weixin_check_qrcode_core( db: &AppDatabase, channel_id: i32, qrcode: &str, -) -> Result { +) -> Result { let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode) .await .map_err(AppCommandError::from)?; @@ -386,7 +386,10 @@ pub async fn weixin_check_qrcode_core( } } - Ok(result) + // Return only the status — never expose bot_token to the frontend + Ok(WeixinQrcodeStatusPublic { + status: result.status, + }) } // --------------------------------------------------------------------------- @@ -569,6 +572,6 @@ pub async fn weixin_check_qrcode( db: tauri::State<'_, AppDatabase>, channel_id: i32, qrcode: String, -) -> Result { +) -> Result { weixin_check_qrcode_core(&db, channel_id, &qrcode).await } diff --git a/src-tauri/src/web/handlers/chat_channel.rs b/src-tauri/src/web/handlers/chat_channel.rs index a542a7f..b12803b 100644 --- a/src-tauri/src/web/handlers/chat_channel.rs +++ b/src-tauri/src/web/handlers/chat_channel.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use crate::app_error::AppCommandError; use crate::app_state::AppState; use crate::commands::chat_channel as cc_commands; -use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; +use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic}; use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; // --------------------------------------------------------------------------- @@ -268,7 +268,7 @@ pub struct WeixinCheckQrcodeParams { pub async fn weixin_check_qrcode( Extension(state): Extension>, Json(params): Json, -) -> Result, AppCommandError> { +) -> Result, AppCommandError> { let result = cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode) .await?; diff --git a/src/components/settings/weixin-qrcode-dialog.tsx b/src/components/settings/weixin-qrcode-dialog.tsx index 74b47df..d9feaca 100644 --- a/src/components/settings/weixin-qrcode-dialog.tsx +++ b/src/components/settings/weixin-qrcode-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { ExternalLink, Loader2, RefreshCw } from "lucide-react" +import { AlertCircle, Loader2, RefreshCw } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" @@ -13,6 +13,11 @@ import { } from "@/components/ui/dialog" import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api" +/** Client-side QR code expiry (5 minutes). */ +const QR_EXPIRY_MS = 5 * 60 * 1000 +/** Show a warning after this many consecutive polling failures. */ +const POLL_ERROR_WARN_THRESHOLD = 3 + interface WeixinQrcodeDialogProps { open: boolean channelId: number @@ -31,29 +36,40 @@ function WeixinQrcodeContent({ }) { const t = useTranslations("ChatChannelSettings") const [qrcodeImg, setQrcodeImg] = useState(null) - const [qrcodeUrl, setQrcodeUrl] = useState(null) - const [imgFailed, setImgFailed] = useState(false) const [qrcodeId, setQrcodeId] = useState(null) const [status, setStatus] = useState<"loading" | "waiting" | "expired">( "loading" ) const [error, setError] = useState(null) + const [pollErrors, setPollErrors] = useState(0) const pollingRef = useRef | null>(null) + const expiryRef = useRef | null>(null) + + // Stabilise callbacks via ref so the polling effect doesn't re-trigger + const onAuthSuccessRef = useRef(onAuthSuccess) + const onCloseRef = useRef(onClose) + useEffect(() => { + onAuthSuccessRef.current = onAuthSuccess + onCloseRef.current = onClose + }) const stopPolling = useCallback(() => { if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null } + if (expiryRef.current) { + clearTimeout(expiryRef.current) + expiryRef.current = null + } }, []) const fetchQrcode = useCallback(async () => { setStatus("loading") setError(null) setQrcodeImg(null) - setQrcodeUrl(null) - setImgFailed(false) setQrcodeId(null) + setPollErrors(0) stopPolling() try { @@ -62,15 +78,9 @@ function WeixinQrcodeContent({ if (result.qrcode_img_content) { const raw = result.qrcode_img_content - // Keep the original URL for fallback link - if (raw.startsWith("http")) { - setQrcodeUrl(raw) - } const imgSrc = raw.startsWith("data:") ? raw - : raw.startsWith("http") - ? raw - : `data:image/png;base64,${raw}` + : `data:image/png;base64,${raw}` setQrcodeImg(imgSrc) } @@ -82,35 +92,42 @@ function WeixinQrcodeContent({ } }, [stopPolling]) - // Fetch QR code on mount + cleanup polling on unmount + // Fetch QR code on mount + cleanup on unmount useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount fetchQrcode() return () => stopPolling() }, [fetchQrcode, stopPolling]) - // Start polling when we have a qrcodeId + // Start polling + expiry timer when we have a qrcodeId useEffect(() => { if (!qrcodeId || status !== "waiting") return + // Client-side expiry guard + expiryRef.current = setTimeout(() => { + stopPolling() + setStatus("expired") + }, QR_EXPIRY_MS) + pollingRef.current = setInterval(async () => { try { const result = await weixinCheckQrcode(channelId, qrcodeId) + setPollErrors(0) if (result.status === "confirmed") { stopPolling() - onAuthSuccess(channelId) - onClose() + onAuthSuccessRef.current(channelId) + onCloseRef.current() } else if (result.status === "expired") { stopPolling() setStatus("expired") } } catch { - // Polling error — keep trying + setPollErrors((n) => n + 1) } }, 2000) return () => stopPolling() - }, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose]) + }, [qrcodeId, status, channelId, stopPolling]) return (
@@ -126,32 +143,23 @@ function WeixinQrcodeContent({ {status === "waiting" && qrcodeImg && ( <> - {!imgFailed ? ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - WeChat QR Code setImgFailed(true)} - /> - - ) : qrcodeUrl ? ( - - - {t("weixinOpenQrcode")} - - ) : null} + {/* eslint-disable-next-line @next/next/no-img-element */} + WeChat QR Code

{t("weixinWaitingScan")}

+ {pollErrors >= POLL_ERROR_WARN_THRESHOLD && ( +
+ + {t("weixinPollError")} +
+ )} )} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e3c31ad..ef4b44b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "انتهت صلاحية رمز QR.", "weixinRefreshQrcode": "تحديث", "weixinWaitingScan": "في انتظار المسح...", - "weixinOpenQrcode": "فتح رمز QR في المتصفح", + "weixinPollError": "الاتصال غير مستقر، جاري إعادة المحاولة...", "connect": "اتصال", "disconnect": "قطع الاتصال", "test": "اختبار الاتصال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 871e582..a9306b7 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "QR-Code abgelaufen.", "weixinRefreshQrcode": "Aktualisieren", "weixinWaitingScan": "Warten auf Scan...", - "weixinOpenQrcode": "QR-Code im Browser öffnen", + "weixinPollError": "Verbindung instabil, erneuter Versuch...", "connect": "Verbinden", "disconnect": "Trennen", "test": "Verbindung testen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 852f4de..237da33 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "QR code expired.", "weixinRefreshQrcode": "Refresh", "weixinWaitingScan": "Waiting for scan...", - "weixinOpenQrcode": "Open QR code in browser", + "weixinPollError": "Connection unstable, retrying...", "connect": "Connect", "disconnect": "Disconnect", "test": "Test Connection", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index ddc8d71..c2ee6c2 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "El código QR ha expirado.", "weixinRefreshQrcode": "Actualizar", "weixinWaitingScan": "Esperando escaneo...", - "weixinOpenQrcode": "Abrir código QR en el navegador", + "weixinPollError": "Conexión inestable, reintentando...", "connect": "Conectar", "disconnect": "Desconectar", "test": "Probar conexión", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d6c1541..44844d1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "QR code expiré.", "weixinRefreshQrcode": "Actualiser", "weixinWaitingScan": "En attente du scan...", - "weixinOpenQrcode": "Ouvrir le QR code dans le navigateur", + "weixinPollError": "Connexion instable, nouvelle tentative...", "connect": "Connecter", "disconnect": "Déconnecter", "test": "Tester la connexion", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 92e5248..611ee68 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "QRコードの有効期限が切れました。", "weixinRefreshQrcode": "更新", "weixinWaitingScan": "スキャン待ち...", - "weixinOpenQrcode": "ブラウザでQRコードを開く", + "weixinPollError": "接続が不安定です。再試行中...", "connect": "接続", "disconnect": "切断", "test": "接続テスト", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 025adb5..a2e36cd 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "QR 코드가 만료되었습니다.", "weixinRefreshQrcode": "새로고침", "weixinWaitingScan": "스캔 대기 중...", - "weixinOpenQrcode": "브라우저에서 QR 코드 열기", + "weixinPollError": "연결이 불안정합니다. 재시도 중...", "connect": "연결", "disconnect": "연결 해제", "test": "연결 테스트", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 329dbab..9b87ffe 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "Código QR expirado.", "weixinRefreshQrcode": "Atualizar", "weixinWaitingScan": "Aguardando escaneamento...", - "weixinOpenQrcode": "Abrir código QR no navegador", + "weixinPollError": "Conexão instável, tentando novamente...", "connect": "Conectar", "disconnect": "Desconectar", "test": "Testar conexão", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0553722..66170c6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "二维码已过期。", "weixinRefreshQrcode": "刷新二维码", "weixinWaitingScan": "等待扫码...", - "weixinOpenQrcode": "在浏览器中打开二维码", + "weixinPollError": "连接不稳定,正在重试...", "connect": "连接", "disconnect": "断开", "test": "测试连接", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 63623c6..5355493 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1719,7 +1719,7 @@ "weixinQrcodeExpired": "二維碼已過期。", "weixinRefreshQrcode": "重新整理", "weixinWaitingScan": "等待掃碼...", - "weixinOpenQrcode": "在瀏覽器中打開二維碼", + "weixinPollError": "連接不穩定,正在重試...", "connect": "連線", "disconnect": "斷開", "test": "測試連線", diff --git a/src/lib/api.ts b/src/lib/api.ts index 0c91d86..ecedc92 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1455,8 +1455,6 @@ export async function weixinCheckQrcode( qrcode: string ): Promise<{ status: string - bot_token?: string - base_url?: string }> { return getTransport().call("weixin_check_qrcode", { channelId, qrcode }) }