use crate::db; use crate::models::GitAccount; use once_cell::sync::Lazy; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::{Arc, Mutex}; use uuid::Uuid; // ── OAuth Authorization Code Flow 共享状态 ──────────────────────────────────── static OAUTH_RESULT: Lazy>>>> = Lazy::new(|| Arc::new(Mutex::new(None))); fn read_setting(key: &str) -> String { let conn = match db::pool().get() { Ok(c) => c, Err(_) => return String::new(), }; conn.query_row( "SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |r| r.get::<_, String>(0), ) .unwrap_or_default() } /// URL 编码(不依赖外部 crate) fn url_encode(s: &str) -> String { let mut out = String::new(); for byte in s.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(*byte as char), b => out.push_str(&format!("%{:02X}", b)), } } out } /// 用 ureq 把 code 换成 access_token fn exchange_code(client_id: &str, client_secret: &str, code: &str, port: u16) -> Result { let redirect_uri = format!("http://localhost:{}/callback", port); let body = format!( "client_id={}&client_secret={}&code={}&redirect_uri={}", url_encode(client_id), url_encode(client_secret), url_encode(code), url_encode(&redirect_uri), ); let resp = ureq::post("https://github.com/login/oauth/access_token") .set("Accept", "application/json") .set("Content-Type", "application/x-www-form-urlencoded") .send_string(&body) .map_err(|e| format!("网络请求失败: {e}"))?; let json: serde_json::Value = resp.into_json() .map_err(|e| format!("解析响应失败: {e}"))?; if let Some(token) = json["access_token"].as_str() { return Ok(token.to_string()); } Err(json["error_description"] .as_str() .or_else(|| json["error"].as_str()) .unwrap_or("token 交换失败") .to_string()) } /// 启动 Authorization Code Flow。 /// 绑定随机端口,后台等待浏览器回调,返回 GitHub 授权 URL。 #[tauri::command] pub fn github_start_oauth() -> Result { let client_id = read_setting("github_client_id"); let client_secret = read_setting("github_client_secret"); if client_id.trim().is_empty() { return Err("未配置 GitHub Client ID,请在设置中填写".to_string()); } if client_secret.trim().is_empty() { return Err("未配置 GitHub Client Secret,请在设置中填写".to_string()); } // 绑定随机可用端口 let listener = TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("无法绑定本地端口: {e}"))?; let port = listener.local_addr().unwrap().port(); // 重置上一次结果 *OAUTH_RESULT.lock().unwrap() = None; let client_id_c = client_id.clone(); let client_secret_c = client_secret.clone(); std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { let mut buf = vec![0u8; 8192]; let n = stream.read(&mut buf).unwrap_or(0); let request = String::from_utf8_lossy(&buf[..n]).to_string(); // 从 "GET /callback?code=xxx HTTP/1.1" 提取 code let code = request.lines().next().and_then(|line| { let path = line.split_whitespace().nth(1)?; let query = path.split('?').nth(1)?; query .split('&') .find(|seg| seg.starts_with("code=")) .map(|seg| seg[5..].to_string()) }); let (status, html, result) = match code { Some(code) => match exchange_code(&client_id_c, &client_secret_c, &code, port) { Ok(token) => ( "200 OK", "

✅ 授权成功

可以关闭此标签页,返回应用继续操作。

", Ok(token), ), Err(e) => ( "400 Bad Request", "

❌ 授权失败

请返回应用重试。

", Err(e), ), }, None => ( "400 Bad Request", "

❌ 未收到授权码

", Err("GitHub 未返回授权码".to_string()), ), }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", html.len() ); let _ = stream.write_all(response.as_bytes()); *OAUTH_RESULT.lock().unwrap() = Some(result); } }); let redirect_uri = format!("http://localhost:{}/callback", port); Ok(format!( "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false", url_encode(&client_id), url_encode(&redirect_uri), )) } /// 前端轮询:None = 等待中,Some(token) = 成功,Err = 失败 #[tauri::command] pub fn github_poll_oauth() -> Result, String> { match &*OAUTH_RESULT.lock().unwrap() { None => Ok(None), Some(Ok(token)) => Ok(Some(token.clone())), Some(Err(e)) => Err(e.clone()), } } /// 检测 github.com:443 是否可达(3 秒超时) fn check_github_reachable() -> bool { use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; let Ok(addrs) = "github.com:443".to_socket_addrs() else { return false; }; addrs .into_iter() .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).is_ok()) } /// 用系统默认浏览器打开 GitHub 授权页,TcpListener 接收回调(Authorization Code Flow) /// 使用系统浏览器避免内嵌 WebView2 的代理/渲染问题 #[tauri::command] pub fn github_open_login_window(_app: tauri::AppHandle) -> Result<(), String> { if !check_github_reachable() { return Err("无法连接 GitHub(github.com:443 超时),请先确认代理已开启".to_string()); } let client_id = read_setting("github_client_id"); let client_secret = read_setting("github_client_secret"); if client_id.trim().is_empty() { return Err("未配置 GitHub Client ID,请在设置中填写".to_string()); } if client_secret.trim().is_empty() { return Err("未配置 GitHub Client Secret,请在设置中填写".to_string()); } *OAUTH_RESULT.lock().unwrap() = None; // 绑定随机本地端口接收 GitHub 回调 let listener = TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("无法绑定本地端口: {e}"))?; let port = listener.local_addr().unwrap().port(); let redirect_uri = format!("http://localhost:{}/callback", port); let auth_url = format!( "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false", url_encode(&client_id), url_encode(&redirect_uri), ); // 后台线程:等待 GitHub 回调,换取 token std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { let mut buf = vec![0u8; 8192]; let n = stream.read(&mut buf).unwrap_or(0); let request = String::from_utf8_lossy(&buf[..n]).to_string(); let code = request.lines().next().and_then(|line| { let path = line.split_whitespace().nth(1)?; let query = path.split('?').nth(1)?; query .split('&') .find(|s| s.starts_with("code=")) .map(|s| s[5..].to_string()) }); let (status, html, result) = match code { Some(code) => match exchange_code(&client_id, &client_secret, &code, port) { Ok(token) => ( "200 OK", "

✅ 授权成功

可以关闭此标签页,返回应用继续操作。

", Ok(token), ), Err(e) => ( "400 Bad Request", "

❌ 授权失败

请返回应用重试。

", Err(e), ), }, None => ( "400 Bad Request", "

❌ 未收到授权码

", Err("GitHub 未返回授权码".to_string()), ), }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", html.len() ); let _ = stream.write_all(response.as_bytes()); *OAUTH_RESULT.lock().unwrap() = Some(result); } }); // 用系统默认浏览器打开(走系统代理,不受 WebView2 限制) open::that(&auth_url).map_err(|e| format!("无法打开浏览器: {e}"))?; Ok(()) } /// 保存 GitHub 账户信息和 token(登录成功后由前端调用) #[tauri::command] pub fn github_save_account( username: String, avatar_url: String, access_token: String, ) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; // 已存在则复用同一个 id let existing_id: Option = conn .query_row( "SELECT id FROM git_accounts WHERE username = ?1", rusqlite::params![username], |row| row.get(0), ) .ok(); let id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string()); conn.execute( "INSERT INTO git_accounts (id, username, avatar_url, provider) VALUES (?1, ?2, ?3, 'github') ON CONFLICT(username) DO UPDATE SET avatar_url = excluded.avatar_url", rusqlite::params![id, username, avatar_url], ) .map_err(|e| e.to_string())?; // token 存入 settings 表 conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('github_token', ?1)", rusqlite::params![access_token], ) .map_err(|e| e.to_string())?; let account = conn .query_row( "SELECT id, username, avatar_url, provider, created_at FROM git_accounts WHERE id = ?1", rusqlite::params![id], GitAccount::from_row, ) .map_err(|e| e.to_string())?; Ok(account) } /// 获取当前已登录账户(无则返回 None) #[tauri::command] pub fn github_get_account() -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; match conn.query_row( "SELECT id, username, avatar_url, provider, created_at FROM git_accounts LIMIT 1", [], GitAccount::from_row, ) { Ok(account) => Ok(Some(account)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.to_string()), } } /// 获取已存储的 access_token(前端调用 GitHub API 时使用) #[tauri::command] pub fn github_get_token() -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; match conn.query_row( "SELECT value FROM settings WHERE key = 'github_token'", [], |row| row.get::<_, String>(0), ) { Ok(token) if !token.is_empty() => Ok(Some(token)), _ => Ok(None), } } /// 获取 GitHub Client ID(前端发起 Device Flow 时使用) #[tauri::command] pub fn github_get_client_id() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let value: String = conn .query_row( "SELECT value FROM settings WHERE key = 'github_client_id'", [], |row| row.get(0), ) .unwrap_or_default(); if value.trim().is_empty() { return Err("未配置 GitHub Client ID,请在「设置」页面填写后再登录".to_string()); } Ok(value) } /// 登出:清除账户和 token #[tauri::command] pub fn github_logout() -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute("DELETE FROM git_accounts", []) .map_err(|e| e.to_string())?; conn.execute( "DELETE FROM settings WHERE key = 'github_token'", [], ) .map_err(|e| e.to_string())?; Ok(()) }