Release version 0.2.6
非活跃连接在 1 分钟内没有活动就会被自动断开; 临时会话被顶替后立即断开连接; 设置界面支持版本控制、github账号管理、Git服务器管理; 增强git凭据处理,现在需要认证时会弹框来支持凭据自动处理。
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user