Pools: löschbar mit Zuordnungs-Auflösung, Neuanmelden über Keychain, Key-Status

- delete_pool löst Projektzuordnungen (Terminal-Config bleibt), Dialog
  zeigt betroffene Projekte
- oauth_login: nur bei ungenutztem Pool, löscht vorher den suffixierten
  Keychain-Eintrag; Warn-Dialog mit keychain_status; oauth_refresh raus
- PoolInfo.hasCredentials: apikey-Dateicheck; Button Key ändern/eintragen,
  einheitliche Button-Breite
- Starten deaktiviert ohne Pool bzw. ohne Key (Tooltip unterscheidet)
- Icon-Pfad im Terminal-Dialog gekürzt mit Hover-Popover
This commit is contained in:
marcus.hinz
2026-07-04 13:27:54 +02:00
parent fc560d4e5f
commit 9b008cbf51
5 changed files with 258 additions and 70 deletions
+88 -28
View File
@@ -264,6 +264,8 @@ struct PoolInfo {
#[serde(rename = "credentialType")]
credential_type: String,
projects: Vec<String>,
#[serde(rename = "hasCredentials")]
has_credentials: bool,
}
fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
@@ -278,10 +280,19 @@ fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
// oauth: Credentials liegen im Keychain, dessen Prüfung wäre ein
// security-Aufruf pro Pool im 3-s-Polling — deshalb hier immer true.
let has_credentials = match pool.credential_type.as_str() {
"apikey" => fs::read_to_string(entry.path().join(APIKEY_FILE))
.map(|s| !s.trim().is_empty())
.unwrap_or(false),
_ => true,
};
pools.push(PoolInfo {
projects: projects_using_pool(paths, &pool.name)?,
name: pool.name,
credential_type: pool.credential_type,
has_credentials,
});
}
pools.sort_by(|a, b| a.name.cmp(&b.name));
@@ -346,14 +357,15 @@ fn create_oauth_pool_in(
}
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt.
/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die
/// Zuordnung (Terminal-Einstellungen bleiben erhalten).
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() {
return Err(format!("Pool nicht gefunden: {name}"));
}
let users = projects_using_pool(paths, name)?;
if !users.is_empty() {
return Err(format!("Pool ist Projekten zugeordnet: {}", users.join(", ")));
for project in projects_using_pool(paths, name)? {
unassign_pool_in(paths, &project)?;
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}
@@ -441,29 +453,59 @@ fn set_apikey(pool: String, key: String) -> Result<(), String> {
set_apikey_in(&Paths::real(), &pool, &key)
}
/// Meldet einen bestehenden oauth-Pool neu an.
#[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> {
run_oauth_flow(&oauth_credential_path(&Paths::real(), &pool)?)
/// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen
/// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über
/// den Pool-Pfad.
fn keychain_service(paths: &Paths, pool: &str) -> String {
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("Claude Code-credentials-{}", &hex[..8])
}
/// Erneuert das Access-Token über das gespeicherte Refresh-Token.
#[tauri::command]
fn oauth_refresh(pool: String) -> Result<(), String> {
let cred_path = oauth_credential_path(&Paths::real(), &pool)?;
let raw = fs::read_to_string(&cred_path)
.map_err(|e| format!("{}: {e}", cred_path.display()))?;
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
let refresh = v["claudeAiOauth"]["refreshToken"]
.as_str()
.ok_or("kein refreshToken in Credential-Datei")?;
fn keychain_entry_exists(service: &str) -> Result<bool, String> {
let out = Command::new("security")
.args(["find-generic-password", "-s", service])
.output()
.map_err(|e| e.to_string())?;
Ok(out.status.success())
}
let body = serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": CLIENT_ID,
});
exchange_and_store(body, &cred_path)
#[tauri::command]
fn keychain_status(pool: String) -> Result<bool, String> {
keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
}
/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher
/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten
/// Session-Start auch greift (claude importiert die frische
/// .credentials.json und legt den Eintrag neu an).
#[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real();
let cred_path = oauth_credential_path(&paths, &pool)?;
let running: Vec<String> = projects_using_pool(&paths, &pool)?
.into_iter()
.filter(|p| is_running(p))
.collect();
if !running.is_empty() {
return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
running.join(", ")
));
}
let service = keychain_service(&paths, &pool);
if keychain_entry_exists(&service)? {
let out = Command::new("security")
.args(["delete-generic-password", "-s", &service])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
run_oauth_flow(&cred_path)
}
fn open_project(project: &str) -> Result<(), String> {
@@ -841,7 +883,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
stop_project,
restart_project,
oauth_login,
oauth_refresh,
keychain_status,
set_apikey,
terminal::open_terminal
])
@@ -1037,14 +1079,32 @@ mod tests {
}
#[test]
fn pool_loeschen_mit_zuordnung_scheitert() {
fn pool_loeschen_loest_zuordnungen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
let err = delete_pool_in(&p, "kunde").unwrap_err();
assert!(err.contains("proj"));
assert!(p.pool_dir("kunde").exists());
delete_pool_in(&p, "kunde").unwrap();
assert!(!p.pool_dir("kunde").exists());
assert!(!p.project_config("proj").exists());
}
#[test]
fn pool_loeschen_erhaelt_terminal_config() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), icon: None, title: None },
)
.unwrap();
delete_pool_in(&p, "kunde").unwrap();
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool, None);
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
}
#[test]