优化终端里的git操作
This commit is contained in:
@@ -3,51 +3,36 @@ use std::collections::HashMap;
|
|||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::db::AppDatabase;
|
|
||||||
use crate::git_credential;
|
use crate::git_credential;
|
||||||
use crate::terminal::error::TerminalError;
|
use crate::terminal::error::TerminalError;
|
||||||
use crate::terminal::manager::{SpawnOptions, TerminalManager};
|
use crate::terminal::manager::{SpawnOptions, TerminalManager};
|
||||||
use crate::terminal::types::TerminalInfo;
|
use crate::terminal::types::TerminalInfo;
|
||||||
|
|
||||||
/// Temp files created for a terminal credential session.
|
/// Build extra env vars for the terminal session.
|
||||||
struct TerminalCredFiles {
|
|
||||||
cred_file: std::path::PathBuf,
|
|
||||||
helper_script: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build extra env vars and temp credential files for the terminal session.
|
|
||||||
///
|
///
|
||||||
/// Uses `credential.helper` with a custom credential helper script that speaks
|
/// Uses `credential.helper` with a script that calls the app binary with
|
||||||
/// git's structured credential protocol (host/protocol on stdin, username/password
|
/// `--credential-helper`. The binary opens the DB, looks up the matching
|
||||||
/// on stdout). This is added via `GIT_CONFIG_COUNT` which APPENDS to the user's
|
/// account, and outputs credentials. No credentials are written to disk.
|
||||||
/// existing credential helpers (e.g. macOS Keychain) for multi-valued keys.
|
fn prepare_credential_env(
|
||||||
/// Our helper is tried first; if it has no match, git falls through to existing helpers.
|
|
||||||
async fn prepare_credential_env(
|
|
||||||
db: &AppDatabase,
|
|
||||||
app_data_dir: &std::path::Path,
|
app_data_dir: &std::path::Path,
|
||||||
terminal_id: &str,
|
) -> Option<HashMap<String, String>> {
|
||||||
) -> (Option<HashMap<String, String>>, Option<TerminalCredFiles>) {
|
// Get the path to the current running binary
|
||||||
let accounts = match git_credential::load_github_accounts(&db.conn).await {
|
let app_binary = match std::env::current_exe() {
|
||||||
Some(s) if !s.accounts.is_empty() => s.accounts,
|
Ok(p) => p,
|
||||||
_ => return (None, None),
|
Err(e) => {
|
||||||
};
|
eprintln!("[TERM] failed to get current exe path: {}", e);
|
||||||
|
return None;
|
||||||
let cred_file = app_data_dir.join(format!("git-creds-{}.tmp", terminal_id));
|
|
||||||
if let Err(e) = git_credential::write_credential_store_file(&accounts, &cred_file) {
|
|
||||||
eprintln!("[TERM] failed to write credential store file: {}", e);
|
|
||||||
return (None, None);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let helper_script = match git_credential::create_credential_helper_script(
|
let helper_script = match git_credential::create_credential_helper_script(
|
||||||
app_data_dir,
|
app_data_dir,
|
||||||
&cred_file,
|
&app_binary,
|
||||||
terminal_id,
|
|
||||||
) {
|
) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[TERM] failed to create credential helper script: {}", e);
|
eprintln!("[TERM] failed to create credential helper script: {}", e);
|
||||||
let _ = std::fs::remove_file(&cred_file);
|
return None;
|
||||||
return (None, None);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,19 +47,14 @@ async fn prepare_credential_env(
|
|||||||
"GIT_CONFIG_KEY_0".to_string(),
|
"GIT_CONFIG_KEY_0".to_string(),
|
||||||
"credential.helper".to_string(),
|
"credential.helper".to_string(),
|
||||||
);
|
);
|
||||||
// credential.helper values without '!' prefix get "git-credential-" prepended.
|
// The '!' prefix tells git to run as a raw shell command (not git-credential-<name>).
|
||||||
// The '!' prefix tells git to run it as a raw shell command.
|
|
||||||
// Paths with spaces (e.g. "Application Support") must be quoted.
|
// Paths with spaces (e.g. "Application Support") must be quoted.
|
||||||
env.insert(
|
env.insert(
|
||||||
"GIT_CONFIG_VALUE_0".to_string(),
|
"GIT_CONFIG_VALUE_0".to_string(),
|
||||||
format!("!\"{}\"", helper_path_str),
|
format!("!\"{}\"", helper_path_str),
|
||||||
);
|
);
|
||||||
|
|
||||||
let files = TerminalCredFiles {
|
Some(env)
|
||||||
cred_file,
|
|
||||||
helper_script,
|
|
||||||
};
|
|
||||||
(Some(env), Some(files))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -82,11 +62,9 @@ pub async fn terminal_spawn(
|
|||||||
working_dir: String,
|
working_dir: String,
|
||||||
initial_command: Option<String>,
|
initial_command: Option<String>,
|
||||||
manager: State<'_, TerminalManager>,
|
manager: State<'_, TerminalManager>,
|
||||||
db: State<'_, AppDatabase>,
|
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
window: tauri::WebviewWindow,
|
window: tauri::WebviewWindow,
|
||||||
) -> Result<String, TerminalError> {
|
) -> Result<String, TerminalError> {
|
||||||
// Generate terminal ID early so we can use it for the credential file name
|
|
||||||
let terminal_id = uuid::Uuid::new_v4().to_string();
|
let terminal_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
let app_data_dir = app_handle
|
let app_data_dir = app_handle
|
||||||
@@ -94,12 +72,7 @@ pub async fn terminal_spawn(
|
|||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||||
|
|
||||||
let (extra_env, cred_files) =
|
let extra_env = prepare_credential_env(&app_data_dir);
|
||||||
prepare_credential_env(&db, &app_data_dir, &terminal_id).await;
|
|
||||||
|
|
||||||
let temp_files = cred_files
|
|
||||||
.map(|f| vec![f.cred_file, f.helper_script])
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
manager.spawn_with_id(
|
manager.spawn_with_id(
|
||||||
SpawnOptions {
|
SpawnOptions {
|
||||||
@@ -108,7 +81,7 @@ pub async fn terminal_spawn(
|
|||||||
owner_window_label: window.label().to_string(),
|
owner_window_label: window.label().to_string(),
|
||||||
initial_command,
|
initial_command,
|
||||||
extra_env,
|
extra_env,
|
||||||
temp_files,
|
temp_files: vec![],
|
||||||
},
|
},
|
||||||
app_handle,
|
app_handle,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,103 +7,32 @@ use crate::models::system::{GitHubAccount, GitHubAccountsSettings};
|
|||||||
|
|
||||||
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
|
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
|
||||||
|
|
||||||
/// Write a git credential-store file containing all stored accounts.
|
/// Create a credential helper that calls the app binary directly with
|
||||||
|
/// `--credential-helper` flag. The app binary opens the DB, looks up
|
||||||
|
/// the matching account, and outputs credentials to stdout.
|
||||||
///
|
///
|
||||||
/// The credential-store format is one URL per line:
|
/// This is the simplest and most reliable approach:
|
||||||
/// `https://username:token@hostname`
|
/// - No HTTP server, no temp credential files
|
||||||
///
|
/// - Always reads latest accounts from DB
|
||||||
/// Returns the path to the written file.
|
/// - No special-character / encoding issues (Rust handles strings natively)
|
||||||
pub fn write_credential_store_file(
|
/// - Single shared script across all terminals
|
||||||
accounts: &[GitHubAccount],
|
|
||||||
file_path: &Path,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let mut content = String::new();
|
|
||||||
for account in accounts {
|
|
||||||
let host = extract_host(&account.server_url).unwrap_or_default();
|
|
||||||
if host.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// URL-encode username and token to handle special characters
|
|
||||||
let username = urlencoding::encode(&account.username);
|
|
||||||
let token = urlencoding::encode(&account.token);
|
|
||||||
content.push_str(&format!("https://{}:{}@{}\n", username, token, host));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut file = std::fs::File::create(file_path)?;
|
|
||||||
file.write_all(content.as_bytes())?;
|
|
||||||
|
|
||||||
// Restrict permissions on Unix (owner read/write only)
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(file_path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a credential helper script that reads from a credential-store file
|
|
||||||
/// using git's structured credential protocol.
|
|
||||||
///
|
|
||||||
/// Git's credential helper protocol passes structured key=value pairs on stdin:
|
|
||||||
/// protocol=https
|
|
||||||
/// host=github.com
|
|
||||||
///
|
|
||||||
/// And expects the response on stdout:
|
|
||||||
/// username=xxx
|
|
||||||
/// password=xxx
|
|
||||||
///
|
|
||||||
/// This is far more reliable than GIT_ASKPASS (which requires parsing English prompts).
|
|
||||||
/// The script is per-terminal (named with terminal_id) to avoid race conditions.
|
|
||||||
pub fn create_credential_helper_script(
|
pub fn create_credential_helper_script(
|
||||||
app_data_dir: &Path,
|
app_data_dir: &Path,
|
||||||
cred_store_path: &Path,
|
app_binary_path: &Path,
|
||||||
terminal_id: &str,
|
|
||||||
) -> std::io::Result<PathBuf> {
|
) -> std::io::Result<PathBuf> {
|
||||||
let cred_store_str = cred_store_path.to_string_lossy();
|
let binary_str = app_binary_path.to_string_lossy();
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let script_path = app_data_dir.join(format!("git-credential-codeg-{}.sh", &terminal_id[..8]));
|
let script_path = app_data_dir.join("git-credential-codeg.sh");
|
||||||
let content = format!(
|
let content = format!(
|
||||||
r#"#!/bin/sh
|
r#"#!/bin/sh
|
||||||
# Codeg credential helper: reads from credential store file.
|
# Codeg credential helper — calls the app binary to look up credentials.
|
||||||
# Only responds to "get" action; ignores "store" and "erase".
|
# Only responds to "get" action; ignores "store" and "erase".
|
||||||
[ "$1" != "get" ] && exit 0
|
[ "$1" != "get" ] && exit 0
|
||||||
|
exec "{binary}" --credential-helper < /dev/stdin
|
||||||
CRED_FILE="{cred_file}"
|
|
||||||
[ ! -f "$CRED_FILE" ] && exit 0
|
|
||||||
|
|
||||||
# Read protocol and host from stdin
|
|
||||||
HOST=""
|
|
||||||
PROTO=""
|
|
||||||
while IFS='=' read -r key value; do
|
|
||||||
[ -z "$key" ] && break
|
|
||||||
case "$key" in
|
|
||||||
host) HOST="$value" ;;
|
|
||||||
protocol) PROTO="$value" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -z "$HOST" ] && exit 0
|
|
||||||
|
|
||||||
# Find matching line: https://user:pass@host
|
|
||||||
LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1)
|
|
||||||
[ -z "$LINE" ] && exit 0
|
|
||||||
|
|
||||||
# Parse username and password from https://user:pass@host
|
|
||||||
USERPASS=$(echo "$LINE" | sed 's|https*://||' | sed 's|@.*||')
|
|
||||||
USER=$(echo "$USERPASS" | cut -d: -f1)
|
|
||||||
PASS=$(echo "$USERPASS" | cut -d: -f2-)
|
|
||||||
|
|
||||||
[ -z "$USER" ] && exit 0
|
|
||||||
|
|
||||||
echo "username=$USER"
|
|
||||||
echo "password=$PASS"
|
|
||||||
"#,
|
"#,
|
||||||
cred_file = cred_store_str
|
binary = binary_str
|
||||||
);
|
);
|
||||||
std::fs::write(&script_path, content)?;
|
std::fs::write(&script_path, content)?;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -113,52 +42,112 @@ echo "password=$PASS"
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let script_path = app_data_dir.join(format!("git-credential-codeg-{}.bat", &terminal_id[..8]));
|
let script_path = app_data_dir.join("git-credential-codeg.bat");
|
||||||
let content = format!(
|
let content = format!(
|
||||||
r#"@echo off
|
r#"@echo off
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
if not "%~1"=="get" exit /b 0
|
if not "%~1"=="get" exit /b 0
|
||||||
|
"{binary}" --credential-helper
|
||||||
set "CRED_FILE={cred_file}"
|
|
||||||
if not exist "!CRED_FILE!" exit /b 0
|
|
||||||
|
|
||||||
set "HOST="
|
|
||||||
set "PROTO="
|
|
||||||
:readloop
|
|
||||||
set /p "LINE=" || goto :match
|
|
||||||
for /f "tokens=1,* delims==" %%a in ("!LINE!") do (
|
|
||||||
if "%%a"=="host" set "HOST=%%b"
|
|
||||||
if "%%a"=="protocol" set "PROTO=%%b"
|
|
||||||
)
|
|
||||||
if defined LINE goto :readloop
|
|
||||||
|
|
||||||
:match
|
|
||||||
if not defined HOST exit /b 0
|
|
||||||
|
|
||||||
for /f "usebackq delims=" %%L in ("!CRED_FILE!") do (
|
|
||||||
echo %%L | findstr /i "!HOST!" >nul
|
|
||||||
if !errorlevel! equ 0 (
|
|
||||||
set "FOUND=%%L"
|
|
||||||
goto :parse
|
|
||||||
)
|
|
||||||
)
|
|
||||||
exit /b 0
|
|
||||||
|
|
||||||
:parse
|
|
||||||
set "FOUND=!FOUND:https://=!"
|
|
||||||
for /f "tokens=1 delims=@" %%a in ("!FOUND!") do set "USERPASS=%%a"
|
|
||||||
for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do (
|
|
||||||
echo username=%%a
|
|
||||||
echo password=%%b
|
|
||||||
)
|
|
||||||
"#,
|
"#,
|
||||||
cred_file = cred_store_str
|
binary = binary_str
|
||||||
);
|
);
|
||||||
std::fs::write(&script_path, content)?;
|
std::fs::write(&script_path, content)?;
|
||||||
Ok(script_path)
|
Ok(script_path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the credential helper mode (called from main when `--credential-helper` is detected).
|
||||||
|
///
|
||||||
|
/// Reads git's credential protocol from stdin (host=xxx, protocol=xxx),
|
||||||
|
/// opens the DB, finds the matching account, and outputs username/password
|
||||||
|
/// to stdout. Exits immediately — does NOT start the Tauri GUI.
|
||||||
|
pub fn run_credential_helper() {
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
|
// Read host from stdin (git credential protocol)
|
||||||
|
let mut host = String::new();
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
if line.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("host=") {
|
||||||
|
host = value.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve app data dir the same way Tauri does: ~/Library/Application Support/<identifier>
|
||||||
|
let app_data_dir = match resolve_app_data_dir() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db_path = app_data_dir.join("codeg.db");
|
||||||
|
if !db_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open DB with a lightweight synchronous connection (no async runtime needed)
|
||||||
|
let db_url = format!(
|
||||||
|
"sqlite:{}?mode=ro",
|
||||||
|
urlencoding::encode(&db_path.to_string_lossy())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use a minimal tokio runtime just for the DB query
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let opts = sea_orm::ConnectOptions::new(db_url);
|
||||||
|
let conn = match sea_orm::Database::connect(opts).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = match load_github_accounts(&conn).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote_url = format!("https://{}", host);
|
||||||
|
if let Some(account) = find_matching_account(&settings.accounts, &remote_url) {
|
||||||
|
println!("username={}", account.username);
|
||||||
|
println!("password={}", account.token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the app data directory (same path Tauri uses).
|
||||||
|
fn resolve_app_data_dir() -> Option<std::path::PathBuf> {
|
||||||
|
// On macOS: ~/Library/Application Support/app.codeg
|
||||||
|
// On Linux: ~/.local/share/app.codeg
|
||||||
|
// On Windows: %APPDATA%/app.codeg
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
dirs::data_dir().map(|d| d.join("app.codeg"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure the GIT_ASKPASS helper script exists in the app data directory.
|
/// Ensure the GIT_ASKPASS helper script exists in the app data directory.
|
||||||
/// Returns the path to the script.
|
/// Returns the path to the script.
|
||||||
pub fn ensure_askpass_script(app_data_dir: &Path) -> std::io::Result<PathBuf> {
|
pub fn ensure_askpass_script(app_data_dir: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod acp;
|
|||||||
mod app_error;
|
mod app_error;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod git_credential;
|
pub mod git_credential;
|
||||||
mod models;
|
mod models;
|
||||||
mod network;
|
mod network;
|
||||||
mod parsers;
|
mod parsers;
|
||||||
|
|||||||
@@ -2,5 +2,12 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// When called as a git credential helper, handle it immediately and exit.
|
||||||
|
// This avoids starting the full Tauri GUI runtime.
|
||||||
|
if std::env::args().any(|a| a == "--credential-helper") {
|
||||||
|
codeg_lib::git_credential::run_credential_helper();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
codeg_lib::run()
|
codeg_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user