334 lines
10 KiB
Rust
334 lines
10 KiB
Rust
use serde::Deserialize;
|
|
#[cfg(feature = "tauri-runtime")]
|
|
use tauri::State;
|
|
|
|
use crate::app_error::AppCommandError;
|
|
use crate::db::service::app_metadata_service;
|
|
#[cfg(feature = "tauri-runtime")]
|
|
use crate::db::AppDatabase;
|
|
use crate::models::GitDetectResult;
|
|
#[cfg(feature = "tauri-runtime")]
|
|
use crate::models::GitHubAccountsSettings;
|
|
use crate::models::{GitHubTokenValidation, GitSettings};
|
|
|
|
const GIT_SETTINGS_KEY: &str = "git_settings";
|
|
#[cfg(feature = "tauri-runtime")]
|
|
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Git detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn run_git_version(git_path: &str) -> Result<GitDetectResult, AppCommandError> {
|
|
let output = crate::process::tokio_command(git_path)
|
|
.arg("--version")
|
|
.output()
|
|
.await
|
|
.map_err(|_| {
|
|
AppCommandError::not_found(format!("Cannot execute git at: {git_path}"))
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
return Ok(GitDetectResult {
|
|
installed: false,
|
|
version: None,
|
|
path: Some(git_path.to_string()),
|
|
});
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let version = stdout
|
|
.trim()
|
|
.strip_prefix("git version ")
|
|
.unwrap_or(stdout.trim())
|
|
.to_string();
|
|
|
|
Ok(GitDetectResult {
|
|
installed: true,
|
|
version: Some(version),
|
|
path: Some(git_path.to_string()),
|
|
})
|
|
}
|
|
|
|
async fn detect_git_path() -> Option<String> {
|
|
let which_cmd = if cfg!(target_os = "windows") {
|
|
"where"
|
|
} else {
|
|
"which"
|
|
};
|
|
|
|
let output = crate::process::tokio_command(which_cmd)
|
|
.arg("git")
|
|
.output()
|
|
.await
|
|
.ok()?;
|
|
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let path = String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.next()?
|
|
.trim()
|
|
.to_string();
|
|
|
|
if path.is_empty() {
|
|
None
|
|
} else {
|
|
Some(path)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn detect_git_core(
|
|
conn: &sea_orm::DatabaseConnection,
|
|
) -> Result<GitDetectResult, AppCommandError> {
|
|
let settings = load_git_settings(conn).await?;
|
|
|
|
if let Some(custom) = &settings.custom_path {
|
|
let trimmed = custom.trim();
|
|
if !trimmed.is_empty() {
|
|
return run_git_version(trimmed).await;
|
|
}
|
|
}
|
|
|
|
if let Some(path) = detect_git_path().await {
|
|
return run_git_version(&path).await;
|
|
}
|
|
|
|
match run_git_version("git").await {
|
|
Ok(result) if result.installed => Ok(result),
|
|
_ => Ok(GitDetectResult {
|
|
installed: false,
|
|
version: None,
|
|
path: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn detect_git(
|
|
db: State<'_, AppDatabase>,
|
|
) -> Result<GitDetectResult, AppCommandError> {
|
|
detect_git_core(&db.conn).await
|
|
}
|
|
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn test_git_path(path: String) -> Result<GitDetectResult, AppCommandError> {
|
|
let trimmed = path.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(AppCommandError::invalid_input("Git path cannot be empty"));
|
|
}
|
|
run_git_version(trimmed).await
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Git settings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn load_git_settings(
|
|
conn: &sea_orm::DatabaseConnection,
|
|
) -> Result<GitSettings, AppCommandError> {
|
|
let raw = app_metadata_service::get_value(conn, GIT_SETTINGS_KEY)
|
|
.await
|
|
.map_err(AppCommandError::from)?;
|
|
|
|
match raw {
|
|
Some(raw) => serde_json::from_str::<GitSettings>(&raw).map_err(|e| {
|
|
AppCommandError::configuration_invalid("Failed to parse stored git settings")
|
|
.with_detail(e.to_string())
|
|
}),
|
|
None => Ok(GitSettings::default()),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn get_git_settings(
|
|
db: State<'_, AppDatabase>,
|
|
) -> Result<GitSettings, AppCommandError> {
|
|
load_git_settings(&db.conn).await
|
|
}
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn update_git_settings(
|
|
settings: GitSettings,
|
|
db: State<'_, AppDatabase>,
|
|
) -> Result<GitSettings, AppCommandError> {
|
|
let serialized = serde_json::to_string(&settings).map_err(|e| {
|
|
AppCommandError::invalid_input("Failed to serialize git settings")
|
|
.with_detail(e.to_string())
|
|
})?;
|
|
|
|
app_metadata_service::upsert_value(&db.conn, GIT_SETTINGS_KEY, &serialized)
|
|
.await
|
|
.map_err(AppCommandError::from)?;
|
|
|
|
Ok(settings)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub accounts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
async fn load_github_accounts(
|
|
conn: &sea_orm::DatabaseConnection,
|
|
) -> Result<GitHubAccountsSettings, AppCommandError> {
|
|
let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY)
|
|
.await
|
|
.map_err(AppCommandError::from)?;
|
|
|
|
match raw {
|
|
Some(raw) => serde_json::from_str::<GitHubAccountsSettings>(&raw).map_err(|e| {
|
|
AppCommandError::configuration_invalid("Failed to parse stored GitHub accounts")
|
|
.with_detail(e.to_string())
|
|
}),
|
|
None => Ok(GitHubAccountsSettings::default()),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn get_github_accounts(
|
|
db: State<'_, AppDatabase>,
|
|
) -> Result<GitHubAccountsSettings, AppCommandError> {
|
|
load_github_accounts(&db.conn).await
|
|
}
|
|
|
|
#[cfg(feature = "tauri-runtime")]
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn update_github_accounts(
|
|
settings: GitHubAccountsSettings,
|
|
db: State<'_, AppDatabase>,
|
|
) -> Result<GitHubAccountsSettings, AppCommandError> {
|
|
let serialized = serde_json::to_string(&settings).map_err(|e| {
|
|
AppCommandError::invalid_input("Failed to serialize GitHub accounts")
|
|
.with_detail(e.to_string())
|
|
})?;
|
|
|
|
app_metadata_service::upsert_value(&db.conn, GITHUB_ACCOUNTS_KEY, &serialized)
|
|
.await
|
|
.map_err(AppCommandError::from)?;
|
|
|
|
Ok(settings)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyring token management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn save_account_token(
|
|
account_id: String,
|
|
token: String,
|
|
) -> Result<(), AppCommandError> {
|
|
crate::keyring_store::set_token(&account_id, &token)
|
|
.map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e))
|
|
}
|
|
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn get_account_token(
|
|
account_id: String,
|
|
) -> Result<Option<String>, AppCommandError> {
|
|
Ok(crate::keyring_store::get_token(&account_id))
|
|
}
|
|
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn delete_account_token(
|
|
account_id: String,
|
|
) -> Result<(), AppCommandError> {
|
|
crate::keyring_store::delete_token(&account_id)
|
|
.map_err(|e| AppCommandError::io_error("Failed to delete token from keyring").with_detail(e))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub token validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GitHubUserResponse {
|
|
login: String,
|
|
avatar_url: Option<String>,
|
|
}
|
|
|
|
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
|
pub async fn validate_github_token(
|
|
server_url: String,
|
|
token: String,
|
|
) -> Result<GitHubTokenValidation, AppCommandError> {
|
|
let trimmed_url = server_url.trim().trim_end_matches('/');
|
|
let trimmed_token = token.trim();
|
|
|
|
if trimmed_token.is_empty() {
|
|
return Err(AppCommandError::invalid_input("Token cannot be empty"));
|
|
}
|
|
|
|
// Build API URL: github.com uses api.github.com, enterprise uses {url}/api/v3
|
|
let api_url = if trimmed_url.is_empty()
|
|
|| trimmed_url == "https://github.com"
|
|
|| trimmed_url == "http://github.com"
|
|
{
|
|
"https://api.github.com/user".to_string()
|
|
} else {
|
|
format!("{trimmed_url}/api/v3/user")
|
|
};
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(&api_url)
|
|
.header("Authorization", format!("Bearer {trimmed_token}"))
|
|
.header("User-Agent", "codeg")
|
|
.header("Accept", "application/vnd.github+json")
|
|
.send()
|
|
.await
|
|
.map_err(|e| AppCommandError::network("Failed to connect to GitHub API").with_detail(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status().as_u16();
|
|
let body = response.text().await.unwrap_or_default();
|
|
let message = if status == 401 {
|
|
"Invalid or expired token".to_string()
|
|
} else {
|
|
format!("GitHub API returned status {status}: {body}")
|
|
};
|
|
return Ok(GitHubTokenValidation {
|
|
success: false,
|
|
username: None,
|
|
scopes: vec![],
|
|
avatar_url: None,
|
|
message: Some(message),
|
|
});
|
|
}
|
|
|
|
// Parse scopes from x-oauth-scopes header
|
|
let scopes: Vec<String> = response
|
|
.headers()
|
|
.get("x-oauth-scopes")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| {
|
|
s.split(',')
|
|
.map(|scope| scope.trim().to_string())
|
|
.filter(|scope| !scope.is_empty())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let user = response
|
|
.json::<GitHubUserResponse>()
|
|
.await
|
|
.map_err(|e| {
|
|
AppCommandError::network("Failed to parse GitHub API response")
|
|
.with_detail(e.to_string())
|
|
})?;
|
|
|
|
Ok(GitHubTokenValidation {
|
|
success: true,
|
|
username: Some(user.login),
|
|
scopes,
|
|
avatar_url: user.avatar_url,
|
|
message: None,
|
|
})
|
|
}
|