Release version 0.2.6

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

View File

@@ -1,22 +1,84 @@
use std::collections::HashMap;
use tauri::Manager;
use tauri::State;
use crate::db::AppDatabase;
use crate::git_credential;
use crate::terminal::error::TerminalError;
use crate::terminal::manager::TerminalManager;
use crate::terminal::manager::{SpawnOptions, TerminalManager};
use crate::terminal::types::TerminalInfo;
/// Build extra env vars and a temp credential file for the terminal session.
///
/// Uses `GIT_ASKPASS` with a terminal-specific askpass script that reads from
/// a credential store file. This approach does NOT override the user's
/// existing `credential.helper` configuration (e.g. macOS Keychain).
async fn prepare_credential_env(
db: &AppDatabase,
app_data_dir: &std::path::Path,
terminal_id: &str,
) -> (Option<HashMap<String, String>>, Option<std::path::PathBuf>) {
let accounts = match git_credential::load_github_accounts(&db.conn).await {
Some(s) if !s.accounts.is_empty() => s.accounts,
_ => return (None, 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 askpass_script =
match git_credential::create_terminal_askpass_script(app_data_dir, &cred_file) {
Ok(p) => p,
Err(e) => {
eprintln!("[TERM] failed to create terminal askpass script: {}", e);
let _ = std::fs::remove_file(&cred_file);
return (None, None);
}
};
let mut env = HashMap::new();
env.insert(
"GIT_ASKPASS".to_string(),
askpass_script.to_string_lossy().to_string(),
);
(Some(env), Some(cred_file))
}
#[tauri::command]
pub fn terminal_spawn(
pub async fn terminal_spawn(
working_dir: String,
initial_command: Option<String>,
manager: State<'_, TerminalManager>,
db: State<'_, AppDatabase>,
app_handle: tauri::AppHandle,
window: tauri::WebviewWindow,
) -> Result<String, TerminalError> {
manager.spawn(
working_dir,
window.label().to_string(),
// Generate terminal ID early so we can use it for the credential file name
let terminal_id = uuid::Uuid::new_v4().to_string();
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let (extra_env, cred_file) =
prepare_credential_env(&db, &app_data_dir, &terminal_id).await;
manager.spawn_with_id(
SpawnOptions {
terminal_id,
working_dir,
owner_window_label: window.label().to_string(),
initial_command,
extra_env,
credential_file: cred_file,
},
app_handle,
initial_command,
)
}

View File

@@ -7,6 +7,147 @@ use crate::models::system::{GitHubAccount, GitHubAccountsSettings};
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
/// Write a git credential-store file containing all stored accounts.
///
/// The credential-store format is one URL per line:
/// `https://username:token@hostname`
///
/// Returns the path to the written file.
pub fn write_credential_store_file(
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 terminal-specific GIT_ASKPASS script that reads credentials
/// from a credential-store file, selecting by hostname from the git prompt.
///
/// Unlike the simple askpass script (which uses env vars for a single credential),
/// this one supports multiple hosts by parsing the credential store file.
pub fn create_terminal_askpass_script(
app_data_dir: &Path,
cred_store_path: &Path,
) -> std::io::Result<PathBuf> {
let cred_store_str = cred_store_path.to_string_lossy();
#[cfg(unix)]
{
let script_path = app_data_dir.join("git-askpass-terminal.sh");
// Always overwrite — cred store path may change between sessions
let content = format!(
r#"#!/bin/sh
# Terminal askpass helper: reads from credential store file.
# Parses hostname from git's prompt (e.g. "Username for 'https://github.com': ")
CRED_FILE="{cred_file}"
PROMPT="$1"
# Extract hostname from prompt (between :// and next / or ')
HOST=$(echo "$PROMPT" | sed -n "s|.*://\([^/']*\).*|\1|p")
[ -z "$HOST" ] && exit 1
[ ! -f "$CRED_FILE" ] && exit 1
# Find matching line in credential store
LINE=$(grep -i "@$HOST" "$CRED_FILE" | head -1)
[ -z "$LINE" ] && exit 1
# 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-)
case "$PROMPT" in
*[Uu]sername*) echo "$USER" ;;
*[Pp]assword*) echo "$PASS" ;;
*) exit 1 ;;
esac
"#,
cred_file = cred_store_str
);
std::fs::write(&script_path, content)?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
Ok(script_path)
}
#[cfg(windows)]
{
let script_path = app_data_dir.join("git-askpass-terminal.bat");
let content = format!(
r#"@echo off
setlocal enabledelayedexpansion
set "CRED_FILE={cred_file}"
set "PROMPT=%~1"
:: Extract hostname from prompt
for /f "tokens=2 delims=/" %%a in ("!PROMPT!") do (
for /f "tokens=1 delims=/' " %%b in ("%%a") do set "HOST=%%b"
)
if not defined HOST exit /b 1
if not exist "!CRED_FILE!" exit /b 1
:: Search credential file for matching host
for /f "usebackq delims=" %%L in ("!CRED_FILE!") do (
echo %%L | findstr /i "!HOST!" >nul
if !errorlevel! equ 0 (
set "LINE=%%L"
goto :found
)
)
exit /b 1
:found
:: Parse https://user:pass@host
set "LINE=!LINE:https://=!"
for /f "tokens=1 delims=@" %%a in ("!LINE!") do set "USERPASS=%%a"
for /f "tokens=1,2 delims=:" %%a in ("!USERPASS!") do (
set "USER=%%a"
set "PASS=%%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
);
std::fs::write(&script_path, content)?;
Ok(script_path)
}
}
/// Ensure the GIT_ASKPASS helper script exists in the app data directory.
/// Returns the path to the script.
pub fn ensure_askpass_script(app_data_dir: &Path) -> std::io::Result<PathBuf> {
@@ -113,6 +254,7 @@ fn extract_host(remote_url: &str) -> Option<String> {
/// Find the best matching account for a given remote URL.
///
/// Only returns an account whose server_url hostname matches the remote URL host.
/// When multiple accounts match the same hostname, prefers the one marked `is_default`.
/// Does NOT fall back to unrelated accounts — if no hostname matches, returns None
/// so the caller can fall back to git config defaults.
pub fn find_matching_account<'a>(
@@ -125,11 +267,21 @@ pub fn find_matching_account<'a>(
let remote_host = extract_host(remote_url)?;
accounts.iter().find(|a| {
let account_host = extract_host(&a.server_url)
.unwrap_or_else(|| a.server_url.trim().trim_end_matches('/').to_lowercase());
account_host == remote_host
})
let matching: Vec<&GitHubAccount> = accounts
.iter()
.filter(|a| {
let account_host = extract_host(&a.server_url)
.unwrap_or_else(|| a.server_url.trim().trim_end_matches('/').to_lowercase());
account_host == remote_host
})
.collect();
// Prefer the default account among matches, otherwise take the first
matching
.iter()
.find(|a| a.is_default)
.or(matching.first())
.copied()
}
/// Load GitHub accounts from the database.
@@ -203,7 +355,10 @@ pub async fn try_inject_for_repo(
let askpass = match ensure_askpass_script(app_data_dir) {
Ok(p) => p,
Err(_) => return false,
Err(e) => {
eprintln!("[GIT_CRED] failed to create askpass script: {}", e);
return false;
}
};
inject_credentials(cmd, &account.username, &account.token, &askpass);
@@ -234,7 +389,10 @@ pub async fn try_inject_for_url(
let askpass = match ensure_askpass_script(app_data_dir) {
Ok(p) => p,
Err(_) => return false,
Err(e) => {
eprintln!("[GIT_CRED] failed to create askpass script: {}", e);
return false;
}
};
inject_credentials(cmd, &account.username, &account.token, &askpass);
@@ -304,4 +462,34 @@ mod tests {
let matched = find_matching_account(&accounts, "https://unknown.com/repo");
assert!(matched.is_none());
}
#[test]
fn test_find_matching_account_prefers_default() {
let accounts = vec![
GitHubAccount {
id: "1".into(),
server_url: "https://github.com".into(),
username: "personal".into(),
token: "tok1".into(),
scopes: vec![],
avatar_url: None,
is_default: false,
created_at: String::new(),
},
GitHubAccount {
id: "2".into(),
server_url: "https://github.com".into(),
username: "work".into(),
token: "tok2".into(),
scopes: vec![],
avatar_url: None,
is_default: true,
created_at: String::new(),
},
];
// Should pick the default account when multiple match the same host
let matched = find_matching_account(&accounts, "https://github.com/org/repo.git");
assert_eq!(matched.unwrap().username, "work");
}
}

View File

@@ -17,6 +17,8 @@ struct TerminalInstance {
_child: Box<dyn portable_pty::Child + Send>,
title: String,
owner_window_label: String,
/// Temp credential file to clean up on kill.
credential_file: Option<std::path::PathBuf>,
}
pub struct TerminalManager {
@@ -130,6 +132,16 @@ fn configure_shell_command(cmd: &mut CommandBuilder, shell: &str, initial_comman
}
}
/// Options for spawning a new terminal session.
pub struct SpawnOptions {
pub terminal_id: String,
pub working_dir: String,
pub owner_window_label: String,
pub initial_command: Option<String>,
pub extra_env: Option<HashMap<String, String>>,
pub credential_file: Option<std::path::PathBuf>,
}
impl TerminalManager {
pub fn new() -> Self {
Self {
@@ -137,12 +149,11 @@ impl TerminalManager {
}
}
pub fn spawn(
#[allow(clippy::too_many_arguments)]
pub fn spawn_with_id(
&self,
working_dir: String,
owner_window_label: String,
opts: SpawnOptions,
app_handle: tauri::AppHandle,
initial_command: Option<String>,
) -> Result<String, TerminalError> {
let pty_system = native_pty_system();
@@ -157,8 +168,15 @@ impl TerminalManager {
let shell = resolve_shell();
let mut cmd = CommandBuilder::new(&shell);
configure_shell_command(&mut cmd, &shell, initial_command.as_deref());
cmd.cwd(&working_dir);
configure_shell_command(&mut cmd, &shell, opts.initial_command.as_deref());
cmd.cwd(&opts.working_dir);
// Inject extra environment variables (e.g. git credential helper config)
if let Some(env) = &opts.extra_env {
for (key, value) in env {
cmd.env(key, value);
}
}
let child = pair
.slave
@@ -177,7 +195,7 @@ impl TerminalManager {
.try_clone_reader()
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
let terminal_id = uuid::Uuid::new_v4().to_string();
let terminal_id = opts.terminal_id;
let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>();
@@ -186,7 +204,8 @@ impl TerminalManager {
master: pair.master,
_child: child,
title: "Terminal".to_string(),
owner_window_label,
owner_window_label: opts.owner_window_label,
credential_file: opts.credential_file,
};
self.terminals
@@ -346,6 +365,10 @@ impl TerminalManager {
fn terminate_terminal(instance: &mut TerminalInstance) {
let _ = instance._child.kill();
let _ = instance._child.wait();
// Clean up temp credential file
if let Some(path) = instance.credential_file.take() {
let _ = std::fs::remove_file(&path);
}
}
fn write_loop(mut writer: Box<dyn Write + Send>, rx: mpsc::Receiver<Vec<u8>>) {
@@ -388,8 +411,12 @@ fn read_loop(
}
}
// Terminal exited — remove from map
terminals.lock().unwrap().remove(&terminal_id);
// Terminal exited — remove from map and clean up credential file
if let Some(mut instance) = terminals.lock().unwrap().remove(&terminal_id) {
if let Some(path) = instance.credential_file.take() {
let _ = std::fs::remove_file(&path);
}
}
emit_terminal_exit_event(app_handle, &terminal_id);
}