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 tauri::State;
|
||||||
|
|
||||||
|
use crate::db::AppDatabase;
|
||||||
|
use crate::git_credential;
|
||||||
use crate::terminal::error::TerminalError;
|
use crate::terminal::error::TerminalError;
|
||||||
use crate::terminal::manager::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.
|
||||||
|
///
|
||||||
|
/// 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]
|
#[tauri::command]
|
||||||
pub fn terminal_spawn(
|
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> {
|
||||||
manager.spawn(
|
// Generate terminal ID early so we can use it for the credential file name
|
||||||
working_dir,
|
let terminal_id = uuid::Uuid::new_v4().to_string();
|
||||||
window.label().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,
|
app_handle,
|
||||||
initial_command,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,147 @@ 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.
|
||||||
|
///
|
||||||
|
/// 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.
|
/// 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> {
|
||||||
@@ -113,6 +254,7 @@ fn extract_host(remote_url: &str) -> Option<String> {
|
|||||||
/// Find the best matching account for a given remote URL.
|
/// Find the best matching account for a given remote URL.
|
||||||
///
|
///
|
||||||
/// Only returns an account whose server_url hostname matches the remote URL host.
|
/// 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
|
/// Does NOT fall back to unrelated accounts — if no hostname matches, returns None
|
||||||
/// so the caller can fall back to git config defaults.
|
/// so the caller can fall back to git config defaults.
|
||||||
pub fn find_matching_account<'a>(
|
pub fn find_matching_account<'a>(
|
||||||
@@ -125,11 +267,21 @@ pub fn find_matching_account<'a>(
|
|||||||
|
|
||||||
let remote_host = extract_host(remote_url)?;
|
let remote_host = extract_host(remote_url)?;
|
||||||
|
|
||||||
accounts.iter().find(|a| {
|
let matching: Vec<&GitHubAccount> = accounts
|
||||||
let account_host = extract_host(&a.server_url)
|
.iter()
|
||||||
.unwrap_or_else(|| a.server_url.trim().trim_end_matches('/').to_lowercase());
|
.filter(|a| {
|
||||||
account_host == remote_host
|
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.
|
/// 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) {
|
let askpass = match ensure_askpass_script(app_data_dir) {
|
||||||
Ok(p) => p,
|
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);
|
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) {
|
let askpass = match ensure_askpass_script(app_data_dir) {
|
||||||
Ok(p) => p,
|
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);
|
inject_credentials(cmd, &account.username, &account.token, &askpass);
|
||||||
@@ -304,4 +462,34 @@ mod tests {
|
|||||||
let matched = find_matching_account(&accounts, "https://unknown.com/repo");
|
let matched = find_matching_account(&accounts, "https://unknown.com/repo");
|
||||||
assert!(matched.is_none());
|
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>,
|
_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.
|
||||||
|
credential_file: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TerminalManager {
|
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 {
|
impl TerminalManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -137,12 +149,11 @@ impl TerminalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn spawn_with_id(
|
||||||
&self,
|
&self,
|
||||||
working_dir: String,
|
opts: SpawnOptions,
|
||||||
owner_window_label: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
initial_command: Option<String>,
|
|
||||||
) -> Result<String, TerminalError> {
|
) -> Result<String, TerminalError> {
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
@@ -157,8 +168,15 @@ impl TerminalManager {
|
|||||||
|
|
||||||
let shell = resolve_shell();
|
let shell = resolve_shell();
|
||||||
let mut cmd = CommandBuilder::new(&shell);
|
let mut cmd = CommandBuilder::new(&shell);
|
||||||
configure_shell_command(&mut cmd, &shell, initial_command.as_deref());
|
configure_shell_command(&mut cmd, &shell, opts.initial_command.as_deref());
|
||||||
cmd.cwd(&working_dir);
|
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
|
let child = pair
|
||||||
.slave
|
.slave
|
||||||
@@ -177,7 +195,7 @@ impl TerminalManager {
|
|||||||
.try_clone_reader()
|
.try_clone_reader()
|
||||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
.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>>();
|
let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>();
|
||||||
|
|
||||||
@@ -186,7 +204,8 @@ impl TerminalManager {
|
|||||||
master: pair.master,
|
master: pair.master,
|
||||||
_child: child,
|
_child: child,
|
||||||
title: "Terminal".to_string(),
|
title: "Terminal".to_string(),
|
||||||
owner_window_label,
|
owner_window_label: opts.owner_window_label,
|
||||||
|
credential_file: opts.credential_file,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.terminals
|
self.terminals
|
||||||
@@ -346,6 +365,10 @@ 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
|
||||||
|
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>>) {
|
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
|
// Terminal exited — remove from map and clean up credential file
|
||||||
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() {
|
||||||
|
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