dev-manager-tauri/src-tauri/src/commands/github.rs
lanrtop 0baa304fbe feat: WSL路径支持、项目发布到GitHub、三灯状态指示
- 新增发布到GitHub功能(scan/init/commit/push/register全流程)
- 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式
- 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用)
- ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示
- ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换
- 添加项目支持顶部快速选择文件夹并自动填充ID和名称
- 设置页WSL区块改为下拉检测选择发行版
- Dialog组件支持fixedHeight固定高度模式
2026-04-01 01:36:21 +09:00

360 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("无法连接 GitHubgithub.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(())
}