- 新增发布到GitHub功能(scan/init/commit/push/register全流程) - 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式 - 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用) - ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示 - ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换 - 添加项目支持顶部快速选择文件夹并自动填充ID和名称 - 设置页WSL区块改为下拉检测选择发行版 - Dialog组件支持fixedHeight固定高度模式
360 lines
14 KiB
Rust
360 lines
14 KiB
Rust
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<Arc<Mutex<Option<Result<String, String>>>>> =
|
||
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<String, String>
|
||
{
|
||
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<String, 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());
|
||
}
|
||
|
||
// 绑定随机可用端口
|
||
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",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
|
||
Ok(token),
|
||
),
|
||
Err(e) => (
|
||
"400 Bad Request",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
|
||
Err(e),
|
||
),
|
||
},
|
||
None => (
|
||
"400 Bad Request",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
|
||
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<Option<String>, 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",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
|
||
Ok(token),
|
||
),
|
||
Err(e) => (
|
||
"400 Bad Request",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
|
||
Err(e),
|
||
),
|
||
},
|
||
None => (
|
||
"400 Bad Request",
|
||
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
|
||
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<GitAccount, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
// 已存在则复用同一个 id
|
||
let existing_id: Option<String> = 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<Option<GitAccount>, 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<Option<String>, 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<String, String> {
|
||
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(())
|
||
}
|