Release version 0.2.6

非活跃连接在 1 分钟内没有活动就会被自动断开;
临时会话被顶替后立即断开连接;
设置界面支持版本控制、github账号管理、Git服务器管理;
增强git凭据处理,现在需要认证时会弹框来支持凭据自动处理。
This commit is contained in:
xintaofei
2026-03-21 15:47:17 +08:00
parent d6098df1a8
commit 06b6f43f28
3 changed files with 121 additions and 80 deletions

View File

@@ -9,16 +9,24 @@ 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;
/// Build extra env vars and a temp credential file for the terminal session. /// Temp files created for a terminal credential 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 `GIT_ASKPASS` with a terminal-specific askpass script that reads from /// Uses `credential.helper` with a custom credential helper script that speaks
/// a credential store file. This approach does NOT override the user's /// git's structured credential protocol (host/protocol on stdin, username/password
/// existing `credential.helper` configuration (e.g. macOS Keychain). /// on stdout). This is added via `GIT_CONFIG_COUNT` which APPENDS to the user's
/// existing credential helpers (e.g. macOS Keychain) for multi-valued keys.
/// Our helper is tried first; if it has no match, git falls through to existing helpers.
async fn prepare_credential_env( async fn prepare_credential_env(
db: &AppDatabase, db: &AppDatabase,
app_data_dir: &std::path::Path, app_data_dir: &std::path::Path,
terminal_id: &str, terminal_id: &str,
) -> (Option<HashMap<String, String>>, Option<std::path::PathBuf>) { ) -> (Option<HashMap<String, String>>, Option<TerminalCredFiles>) {
let accounts = match git_credential::load_github_accounts(&db.conn).await { let accounts = match git_credential::load_github_accounts(&db.conn).await {
Some(s) if !s.accounts.is_empty() => s.accounts, Some(s) if !s.accounts.is_empty() => s.accounts,
_ => return (None, None), _ => return (None, None),
@@ -30,23 +38,40 @@ async fn prepare_credential_env(
return (None, None); return (None, None);
} }
let askpass_script = let helper_script = match git_credential::create_credential_helper_script(
match git_credential::create_terminal_askpass_script(app_data_dir, &cred_file) { app_data_dir,
&cred_file,
terminal_id,
) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
eprintln!("[TERM] failed to create terminal askpass script: {}", e); eprintln!("[TERM] failed to create credential helper script: {}", e);
let _ = std::fs::remove_file(&cred_file); let _ = std::fs::remove_file(&cred_file);
return (None, None); return (None, None);
} }
}; };
let helper_path_str = helper_script.to_string_lossy().to_string();
// GIT_CONFIG_COUNT adds config entries that are tried BEFORE file-based config.
// For multi-valued keys like credential.helper, this means our helper runs first;
// if it exits 0 with no output, git falls through to the user's existing helpers.
let mut env = HashMap::new(); let mut env = HashMap::new();
env.insert("GIT_CONFIG_COUNT".to_string(), "1".to_string());
env.insert( env.insert(
"GIT_ASKPASS".to_string(), "GIT_CONFIG_KEY_0".to_string(),
askpass_script.to_string_lossy().to_string(), "credential.helper".to_string(),
);
env.insert(
"GIT_CONFIG_VALUE_0".to_string(),
helper_path_str,
); );
(Some(env), Some(cred_file)) let files = TerminalCredFiles {
cred_file,
helper_script,
};
(Some(env), Some(files))
} }
#[tauri::command] #[tauri::command]
@@ -66,9 +91,13 @@ 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_file) = let (extra_env, cred_files) =
prepare_credential_env(&db, &app_data_dir, &terminal_id).await; 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 {
terminal_id, terminal_id,
@@ -76,7 +105,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,
credential_file: cred_file, temp_files,
}, },
app_handle, app_handle,
) )

View File

@@ -44,47 +44,64 @@ pub fn write_credential_store_file(
Ok(()) Ok(())
} }
/// Create a terminal-specific GIT_ASKPASS script that reads credentials /// Create a credential helper script that reads from a credential-store file
/// from a credential-store file, selecting by hostname from the git prompt. /// using git's structured credential protocol.
/// ///
/// Unlike the simple askpass script (which uses env vars for a single credential), /// Git's credential helper protocol passes structured key=value pairs on stdin:
/// this one supports multiple hosts by parsing the credential store file. /// protocol=https
pub fn create_terminal_askpass_script( /// 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(
app_data_dir: &Path, app_data_dir: &Path,
cred_store_path: &Path, cred_store_path: &Path,
terminal_id: &str,
) -> std::io::Result<PathBuf> { ) -> std::io::Result<PathBuf> {
let cred_store_str = cred_store_path.to_string_lossy(); let cred_store_str = cred_store_path.to_string_lossy();
#[cfg(unix)] #[cfg(unix)]
{ {
let script_path = app_data_dir.join("git-askpass-terminal.sh"); let script_path = app_data_dir.join(format!("git-credential-codeg-{}.sh", &terminal_id[..8]));
// Always overwrite — cred store path may change between sessions
let content = format!( let content = format!(
r#"#!/bin/sh r#"#!/bin/sh
# Terminal askpass helper: reads from credential store file. # Codeg credential helper: reads from credential store file.
# Parses hostname from git's prompt (e.g. "Username for 'https://github.com': ") # Only responds to "get" action; ignores "store" and "erase".
[ "$1" != "get" ] && exit 0
CRED_FILE="{cred_file}" CRED_FILE="{cred_file}"
PROMPT="$1" [ ! -f "$CRED_FILE" ] && exit 0
# Extract hostname from prompt (between :// and next / or ') # Read protocol and host from stdin
HOST=$(echo "$PROMPT" | sed -n "s|.*://\([^/']*\).*|\1|p") HOST=""
[ -z "$HOST" ] && exit 1 PROTO=""
[ ! -f "$CRED_FILE" ] && exit 1 while IFS='=' read -r key value; do
[ -z "$key" ] && break
case "$key" in
host) HOST="$value" ;;
protocol) PROTO="$value" ;;
esac
done
# Find matching line in credential store [ -z "$HOST" ] && exit 0
# Find matching line: https://user:pass@host
LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1) LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1)
[ -z "$LINE" ] && exit 1 [ -z "$LINE" ] && exit 0
# Parse username and password from https://user:pass@host # Parse username and password from https://user:pass@host
USERPASS=$(echo "$LINE" | sed 's|https://||' | sed 's|@.*||') USERPASS=$(echo "$LINE" | sed 's|https*://||' | sed 's|@.*||')
USER=$(echo "$USERPASS" | cut -d: -f1) USER=$(echo "$USERPASS" | cut -d: -f1)
PASS=$(echo "$USERPASS" | cut -d: -f2-) PASS=$(echo "$USERPASS" | cut -d: -f2-)
case "$PROMPT" in [ -z "$USER" ] && exit 0
*[Uu]sername*) echo "$USER" ;;
*[Pp]assword*) echo "$PASS" ;; echo "username=$USER"
*) exit 1 ;; echo "password=$PASS"
esac
"#, "#,
cred_file = cred_store_str cred_file = cred_store_str
); );
@@ -96,50 +113,44 @@ esac
#[cfg(windows)] #[cfg(windows)]
{ {
let script_path = app_data_dir.join("git-askpass-terminal.bat"); let script_path = app_data_dir.join(format!("git-credential-codeg-{}.bat", &terminal_id[..8]));
let content = format!( let content = format!(
r#"@echo off r#"@echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
if not "%~1"=="get" exit /b 0
set "CRED_FILE={cred_file}" set "CRED_FILE={cred_file}"
set "PROMPT=%~1" if not exist "!CRED_FILE!" exit /b 0
:: Extract hostname from prompt set "HOST="
for /f "tokens=2 delims=/" %%a in ("!PROMPT!") do ( set "PROTO="
for /f "tokens=1 delims=/' " %%b in ("%%a") do set "HOST=%%b" :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 not defined HOST exit /b 1 if defined LINE goto :readloop
if not exist "!CRED_FILE!" exit /b 1
:match
if not defined HOST exit /b 0
:: Search credential file for matching host
for /f "usebackq delims=" %%L in ("!CRED_FILE!") do ( for /f "usebackq delims=" %%L in ("!CRED_FILE!") do (
echo %%L | findstr /i "!HOST!" >nul echo %%L | findstr /i "!HOST!" >nul
if !errorlevel! equ 0 ( if !errorlevel! equ 0 (
set "LINE=%%L" set "FOUND=%%L"
goto :found goto :parse
) )
) )
exit /b 1 exit /b 0
:found :parse
:: Parse https://user:pass@host set "FOUND=!FOUND:https://=!"
set "LINE=!LINE:https://=!" for /f "tokens=1 delims=@" %%a in ("!FOUND!") do set "USERPASS=%%a"
for /f "tokens=1 delims=@" %%a in ("!LINE!") do set "USERPASS=%%a"
for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do ( for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do (
set "USER=%%a" echo username=%%a
set "PASS=%%b" echo password=%%b
) )
echo !PROMPT! | findstr /i "username" >nul
if !errorlevel! equ 0 (
echo !USER!
exit /b
)
echo !PROMPT! | findstr /i "password" >nul
if !errorlevel! equ 0 (
echo !PASS!
exit /b
)
exit /b 1
"#, "#,
cred_file = cred_store_str cred_file = cred_store_str
); );

View File

@@ -17,8 +17,8 @@ struct TerminalInstance {
_child: Box<dyn portable_pty::Child + Send>, _child: Box<dyn portable_pty::Child + Send>,
title: String, title: String,
owner_window_label: String, owner_window_label: String,
/// Temp credential file to clean up on kill. /// Temp files (credential store + helper script) to clean up on exit.
credential_file: Option<std::path::PathBuf>, temp_files: Vec<std::path::PathBuf>,
} }
pub struct TerminalManager { pub struct TerminalManager {
@@ -139,7 +139,7 @@ pub struct SpawnOptions {
pub owner_window_label: String, pub owner_window_label: String,
pub initial_command: Option<String>, pub initial_command: Option<String>,
pub extra_env: Option<HashMap<String, String>>, pub extra_env: Option<HashMap<String, String>>,
pub credential_file: Option<std::path::PathBuf>, pub temp_files: Vec<std::path::PathBuf>,
} }
impl TerminalManager { impl TerminalManager {
@@ -205,7 +205,7 @@ impl TerminalManager {
_child: child, _child: child,
title: "Terminal".to_string(), title: "Terminal".to_string(),
owner_window_label: opts.owner_window_label, owner_window_label: opts.owner_window_label,
credential_file: opts.credential_file, temp_files: opts.temp_files,
}; };
self.terminals self.terminals
@@ -365,8 +365,11 @@ impl TerminalManager {
fn terminate_terminal(instance: &mut TerminalInstance) { fn terminate_terminal(instance: &mut TerminalInstance) {
let _ = instance._child.kill(); let _ = instance._child.kill();
let _ = instance._child.wait(); let _ = instance._child.wait();
// Clean up temp credential file cleanup_temp_files(&mut instance.temp_files);
if let Some(path) = instance.credential_file.take() { }
fn cleanup_temp_files(files: &mut Vec<std::path::PathBuf>) {
for path in files.drain(..) {
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
} }
@@ -411,11 +414,9 @@ fn read_loop(
} }
} }
// Terminal exited — remove from map and clean up credential file // Terminal exited — remove from map and clean up temp files
if let Some(mut instance) = terminals.lock().unwrap().remove(&terminal_id) { if let Some(mut instance) = terminals.lock().unwrap().remove(&terminal_id) {
if let Some(path) = instance.credential_file.take() { cleanup_temp_files(&mut instance.temp_files);
let _ = std::fs::remove_file(&path);
}
} }
emit_terminal_exit_event(app_handle, &terminal_id); emit_terminal_exit_event(app_handle, &terminal_id);