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