From d6098df1a862ed429008829ae5aa6654f1304372 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 21 Mar 2026 15:40:07 +0800 Subject: [PATCH] Release version 0.2.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非活跃连接在 1 分钟内没有活动就会被自动断开; 临时会话被顶替后立即断开连接; 设置界面支持版本控制、github账号管理、Git服务器管理; 增强git凭据处理,现在需要认证时会弹框来支持凭据自动处理。 --- src-tauri/src/commands/terminal.rs | 74 ++++++++++- src-tauri/src/git_credential.rs | 202 ++++++++++++++++++++++++++++- src-tauri/src/terminal/manager.rs | 47 +++++-- 3 files changed, 300 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index d1fef15..3e22aa0 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -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>, Option) { + 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, manager: State<'_, TerminalManager>, + db: State<'_, AppDatabase>, app_handle: tauri::AppHandle, window: tauri::WebviewWindow, ) -> Result { - 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, ) } diff --git a/src-tauri/src/git_credential.rs b/src-tauri/src/git_credential.rs index 6eb6c87..253ac85 100644 --- a/src-tauri/src/git_credential.rs +++ b/src-tauri/src/git_credential.rs @@ -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 { + 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 { @@ -113,6 +254,7 @@ fn extract_host(remote_url: &str) -> Option { /// 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"); + } } diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 0f2073a..3483515 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -17,6 +17,8 @@ struct TerminalInstance { _child: Box, title: String, owner_window_label: String, + /// Temp credential file to clean up on kill. + credential_file: Option, } 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, + pub extra_env: Option>, + pub credential_file: Option, +} + 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, ) -> Result { 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::>(); @@ -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, rx: mpsc::Receiver>) { @@ -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); }