feat(integrations): add review fetching and Linux keyring support
Add a cross-provider Review Center and improve credential handling. Backend integrations fetch and normalize PRs from GitHub, GitLab, Gitea, and Azure DevOps with improved timeouts and parsing. Credentials now use a global lock and support secret-tool on Linux to avoid races. - Normalize review data across providers (GitHub/GitLab/Gitea/Azure) - Serialize credential access with OnceLock and use secret-tool on Linux - Add frontend ReviewCenter component and related UI updates
This commit is contained in:
+147
-5
@@ -8,7 +8,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -2861,6 +2861,14 @@ fn push_args_with_http_1_1(push_args: Vec<OsString>) -> Vec<OsString> {
|
||||
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||
|
||||
const CRED_SERVICE: &str = "tauri_git_lite";
|
||||
static CREDENTIAL_STORE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn credential_store_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
CREDENTIAL_STORE_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredential {
|
||||
@@ -2878,17 +2886,141 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_load(key: &str) -> Result<Option<String>, String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["lookup", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return keyring_load(key),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout)
|
||||
.map(|value| Some(value.trim_end_matches(['\r', '\n']).to_string()))
|
||||
.map_err(|error| format!("System keychain returned unreadable data: {error}"));
|
||||
}
|
||||
if output.status.code() == Some(1) && output.stderr.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"System keychain lookup failed.".to_string()
|
||||
} else {
|
||||
format!("System keychain lookup failed: {detail}")
|
||||
})
|
||||
}
|
||||
|
||||
fn keyring_load(key: &str) -> Result<Option<String>, String> {
|
||||
let entry = cred_entry(key)?;
|
||||
match entry.get_password() {
|
||||
Ok(json) => serde_json::from_str::<StoredCredential>(&json)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}")),
|
||||
Ok(json) => Ok(Some(json)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Keychain access failed: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_save(key: &str, value: &str) -> Result<(), String> {
|
||||
let mut child = match Command::new("secret-tool")
|
||||
.args([
|
||||
"store",
|
||||
"--label=Gitty credentials",
|
||||
"service",
|
||||
CRED_SERVICE,
|
||||
"username",
|
||||
key,
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return cred_entry(key)?
|
||||
.set_password(value)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Could not open the system keychain helper input.".to_string())?
|
||||
.write_all(value.as_bytes())
|
||||
.map_err(|error| format!("Could not pass credentials to the system keychain: {error}"))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|error| format!("System keychain helper failed: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Saving to the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Saving to the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn secret_tool_delete(key: &str) -> Result<(), String> {
|
||||
let output = match Command::new("secret-tool")
|
||||
.args(["clear", "service", CRED_SERVICE, "username", key])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return match cred_entry(key)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Could not start the system keychain helper: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if detail.is_empty() {
|
||||
"Deleting from the system keychain failed.".to_string()
|
||||
} else {
|
||||
format!("Deleting from the system keychain failed: {detail}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
let stored = secret_tool_load(key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let stored = keyring_load(key)?;
|
||||
stored
|
||||
.map(|json| {
|
||||
serde_json::from_str::<StoredCredential>(&json)
|
||||
.map_err(|err| format!("Stored credentials unreadable: {err}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Returns the remote URL used for auth key derivation (upstream remote of the
|
||||
/// current branch, falling back to `origin`, then the first configured remote).
|
||||
#[tauri::command(async)]
|
||||
@@ -3084,6 +3216,8 @@ pub fn cred_save(
|
||||
password: String,
|
||||
mode: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
let mode = match mode.as_deref() {
|
||||
Some("token") => Some("token".to_string()),
|
||||
@@ -3096,6 +3230,9 @@ pub fn cred_save(
|
||||
};
|
||||
let json = serde_json::to_string(&cred)
|
||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_save(&key, &json);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
entry
|
||||
.set_password(&json)
|
||||
.map_err(|err| format!("Saving to keychain failed: {err}"))
|
||||
@@ -3103,7 +3240,12 @@ pub fn cred_save(
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_delete(key: String) -> Result<(), String> {
|
||||
let _guard = credential_store_lock();
|
||||
#[cfg(target_os = "linux")]
|
||||
return secret_tool_delete(&key);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let entry = cred_entry(&key)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!("Deleting from keychain failed: {err}")),
|
||||
|
||||
Reference in New Issue
Block a user