f7473797a9
- Terminal-Fenster als eigener Prozess (--terminal <projekt>) mit eigener PTY, startet claude-sync im Projektordner - Projektliste: ein Button pro Zeile — grün Starten (open_terminal) bzw. rot Beenden (stop_project, SIGTERM auf exakte PID / Ghostty-Quit) - is_running erfasst Ghostty oder laufenden Terminal-Prozess - Status-Kreis ohne Prozess unsichtbar
1067 lines
32 KiB
Rust
1067 lines
32 KiB
Rust
mod terminal;
|
||
|
||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||
use serde::{Deserialize, Serialize};
|
||
use sha2::{Digest, Sha256};
|
||
use std::fs;
|
||
use std::io::{BufRead, BufReader, Write};
|
||
use std::net::TcpListener;
|
||
use std::os::unix::fs::PermissionsExt;
|
||
use std::path::PathBuf;
|
||
use std::process::Command;
|
||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||
|
||
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||
const AUTH_URL: &str = "https://claude.ai/oauth/authorize";
|
||
const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
|
||
const SCOPES: &str = "org:create_api_key user:profile user:inference";
|
||
|
||
/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
|
||
/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt.
|
||
const CREDENTIALS_FILE: &str = ".credentials.json";
|
||
const APIKEY_FILE: &str = "apikey";
|
||
const POOL_FILE: &str = "pool.json";
|
||
const PROJECT_FILE: &str = "ai-control.json";
|
||
|
||
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||
struct Paths {
|
||
home: PathBuf,
|
||
}
|
||
|
||
impl Paths {
|
||
fn real() -> Self {
|
||
Paths {
|
||
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
|
||
}
|
||
}
|
||
|
||
fn projects_dir(&self) -> PathBuf {
|
||
self.home.join("claude-projects")
|
||
}
|
||
|
||
fn pools_dir(&self) -> PathBuf {
|
||
self.home.join(".config").join("ai-control").join("pools")
|
||
}
|
||
|
||
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||
self.pools_dir().join(pool)
|
||
}
|
||
|
||
fn project_config(&self, project: &str) -> PathBuf {
|
||
self.projects_dir().join(project).join(PROJECT_FILE)
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct Project {
|
||
name: String,
|
||
path: String,
|
||
pool: Option<String>,
|
||
running: bool,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
struct Pool {
|
||
name: String,
|
||
#[serde(rename = "credentialType")]
|
||
credential_type: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
struct ProjectConfig {
|
||
pool: String,
|
||
}
|
||
|
||
fn bundle_id(project: &str) -> String {
|
||
format!("com.mitchellh.ghostty.{project}")
|
||
}
|
||
|
||
fn is_running(project: &str) -> bool {
|
||
ghostty_running(project) || !terminal_pids(project).is_empty()
|
||
}
|
||
|
||
fn ghostty_running(project: &str) -> bool {
|
||
let out = Command::new("lsappinfo")
|
||
.args(["find", &format!("bundleid={}", bundle_id(project))])
|
||
.output();
|
||
match out {
|
||
Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
|
||
Err(_) => false,
|
||
}
|
||
}
|
||
|
||
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
|
||
fn terminal_pids(project: &str) -> Vec<u32> {
|
||
let out = Command::new("pgrep")
|
||
.args(["-f", "--", &format!("--terminal {project}$")])
|
||
.output();
|
||
match out {
|
||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||
.lines()
|
||
.filter_map(|l| l.trim().parse().ok())
|
||
.collect(),
|
||
Err(_) => Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn check_name(name: &str) -> Result<(), String> {
|
||
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
|
||
return Err(format!("ungültiger Name: {name}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
|
||
let cfg_path = paths.pool_dir(pool).join(POOL_FILE);
|
||
let raw = fs::read_to_string(&cfg_path)
|
||
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
||
}
|
||
|
||
// ---------- Projekte ----------
|
||
|
||
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||
let mut projects = Vec::new();
|
||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||
for entry in entries {
|
||
let entry = entry.map_err(|e| e.to_string())?;
|
||
let path = entry.path();
|
||
if !path.join(".claude").is_dir() {
|
||
continue;
|
||
}
|
||
let name = entry.file_name().to_string_lossy().into_owned();
|
||
let cfg_path = path.join(PROJECT_FILE);
|
||
let pool = if cfg_path.is_file() {
|
||
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||
let cfg: ProjectConfig = serde_json::from_str(&raw)
|
||
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||
Some(cfg.pool)
|
||
} else {
|
||
None
|
||
};
|
||
projects.push(Project {
|
||
running: is_running(&name),
|
||
path: path.to_string_lossy().into_owned(),
|
||
name,
|
||
pool,
|
||
});
|
||
}
|
||
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
||
Ok(projects)
|
||
}
|
||
|
||
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||
check_name(name)?;
|
||
let dir = paths.projects_dir().join(name);
|
||
if dir.exists() {
|
||
return Err(format!("Projekt existiert bereits: {name}"));
|
||
}
|
||
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||
check_name(name)?;
|
||
let dir = paths.projects_dir().join(name);
|
||
if !dir.is_dir() {
|
||
return Err(format!("Projekt nicht gefunden: {name}"));
|
||
}
|
||
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
|
||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||
return Err(format!("Pool existiert nicht: {pool}"));
|
||
}
|
||
let cfg = ProjectConfig { pool: pool.to_string() };
|
||
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
|
||
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||
let cfg_path = paths.project_config(project);
|
||
fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
||
}
|
||
|
||
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||
let mut users = Vec::new();
|
||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||
for entry in entries {
|
||
let entry = entry.map_err(|e| e.to_string())?;
|
||
let cfg_path = entry.path().join(PROJECT_FILE);
|
||
if !cfg_path.is_file() {
|
||
continue;
|
||
}
|
||
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||
if cfg.pool == pool {
|
||
users.push(entry.file_name().to_string_lossy().into_owned());
|
||
}
|
||
}
|
||
users.sort();
|
||
Ok(users)
|
||
}
|
||
|
||
// ---------- Pools ----------
|
||
|
||
#[derive(Serialize)]
|
||
struct PoolInfo {
|
||
name: String,
|
||
#[serde(rename = "credentialType")]
|
||
credential_type: String,
|
||
projects: Vec<String>,
|
||
}
|
||
|
||
fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
|
||
let mut pools = Vec::new();
|
||
let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
|
||
for entry in entries {
|
||
let entry = entry.map_err(|e| e.to_string())?;
|
||
let cfg_path = entry.path().join(POOL_FILE);
|
||
if !cfg_path.is_file() {
|
||
continue;
|
||
}
|
||
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()))?;
|
||
pools.push(PoolInfo {
|
||
projects: projects_using_pool(paths, &pool.name)?,
|
||
name: pool.name,
|
||
credential_type: pool.credential_type,
|
||
});
|
||
}
|
||
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
||
Ok(pools)
|
||
}
|
||
|
||
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||
check_name(name)?;
|
||
let dir = paths.pool_dir(name);
|
||
if dir.exists() {
|
||
return Err(format!("Pool existiert bereits: {name}"));
|
||
}
|
||
Ok(dir)
|
||
}
|
||
|
||
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
|
||
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||
let pool = Pool {
|
||
name: name.to_string(),
|
||
credential_type: credential_type.to_string(),
|
||
};
|
||
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
|
||
fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||
}
|
||
fs::write(path, content).map_err(|e| e.to_string())?;
|
||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit
|
||
/// apiKeyHelper auf diese Datei, pool.json.
|
||
fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> {
|
||
let dir = check_new_pool(paths, name)?;
|
||
let key = key.trim();
|
||
if key.is_empty() {
|
||
return Err("leerer API-Key".into());
|
||
}
|
||
let key_path = dir.join(APIKEY_FILE);
|
||
write_secret_file(&key_path, &format!("{key}\n"))?;
|
||
let settings = serde_json::json!({
|
||
"apiKeyHelper": format!("cat '{}'", key_path.display()),
|
||
});
|
||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||
fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||
write_pool_json(&dir, name, "apikey")
|
||
}
|
||
|
||
/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den
|
||
/// Pool-Ordner), dann – nur bei Erfolg – pool.json.
|
||
fn create_oauth_pool_in(
|
||
paths: &Paths,
|
||
name: &str,
|
||
login: impl FnOnce(&PathBuf) -> Result<(), String>,
|
||
) -> Result<(), String> {
|
||
let dir = check_new_pool(paths, name)?;
|
||
login(&dir.join(CREDENTIALS_FILE))?;
|
||
write_pool_json(&dir, name, "oauth")
|
||
}
|
||
|
||
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt.
|
||
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(", ")));
|
||
}
|
||
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Schreibt den API-Key eines apikey-Pools neu (0600).
|
||
fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> {
|
||
let p = read_pool(paths, pool)?;
|
||
if p.credential_type != "apikey" {
|
||
return Err(format!("Pool {pool} ist kein apikey-Pool"));
|
||
}
|
||
let key = key.trim();
|
||
if key.is_empty() {
|
||
return Err("leerer API-Key".into());
|
||
}
|
||
write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n"))
|
||
}
|
||
|
||
fn oauth_credential_path(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
|
||
let p = read_pool(paths, pool)?;
|
||
if p.credential_type != "oauth" {
|
||
return Err(format!("Pool {pool} ist kein oauth-Pool"));
|
||
}
|
||
Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE))
|
||
}
|
||
|
||
// ---------- Tauri-Commands ----------
|
||
|
||
#[tauri::command]
|
||
fn list_projects() -> Result<Vec<Project>, String> {
|
||
list_projects_in(&Paths::real())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn create_project(name: String) -> Result<(), String> {
|
||
create_project_in(&Paths::real(), &name)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn delete_project(name: String) -> Result<(), String> {
|
||
delete_project_in(&Paths::real(), &name)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn list_pools() -> Result<Vec<PoolInfo>, String> {
|
||
list_pools_in(&Paths::real())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn create_oauth_pool(name: String) -> Result<(), String> {
|
||
create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
|
||
create_apikey_pool_in(&Paths::real(), &name, &key)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn delete_pool(name: String) -> Result<(), String> {
|
||
delete_pool_in(&Paths::real(), &name)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn assign_pool(project: String, pool: String) -> Result<(), String> {
|
||
assign_pool_in(&Paths::real(), &project, &pool)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn unassign_pool(project: String) -> Result<(), String> {
|
||
unassign_pool_in(&Paths::real(), &project)
|
||
}
|
||
|
||
#[tauri::command]
|
||
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)?)
|
||
}
|
||
|
||
/// 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")?;
|
||
|
||
let body = serde_json::json!({
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": refresh,
|
||
"client_id": CLIENT_ID,
|
||
});
|
||
exchange_and_store(body, &cred_path)
|
||
}
|
||
|
||
fn open_project(project: &str) -> Result<(), String> {
|
||
// Läuft die UI selbst in einer Ghostty-App, erbt `open` deren
|
||
// XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config.
|
||
let out = Command::new("open")
|
||
.env_remove("XDG_CONFIG_HOME")
|
||
.args(["-b", &bundle_id(project)])
|
||
.output()
|
||
.map_err(|e| e.to_string())?;
|
||
if out.status.success() {
|
||
Ok(())
|
||
} else {
|
||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn start_project(project: String) -> Result<(), String> {
|
||
open_project(&project)
|
||
}
|
||
|
||
/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per
|
||
/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle
|
||
/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit.
|
||
#[tauri::command]
|
||
fn stop_project(project: String) -> Result<(), String> {
|
||
let pids = terminal_pids(&project);
|
||
if !pids.is_empty() {
|
||
for pid in pids {
|
||
let out = Command::new("kill")
|
||
.arg(pid.to_string())
|
||
.output()
|
||
.map_err(|e| e.to_string())?;
|
||
if !out.status.success() {
|
||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
if ghostty_running(&project) {
|
||
let out = Command::new("osascript")
|
||
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
|
||
.output()
|
||
.map_err(|e| e.to_string())?;
|
||
if !out.status.success() {
|
||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||
}
|
||
return Ok(());
|
||
}
|
||
Err(format!("{project} läuft nicht"))
|
||
}
|
||
|
||
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
|
||
/// nachfragen), wartet auf das Prozessende und startet sie neu.
|
||
#[tauri::command]
|
||
fn restart_project(project: String) -> Result<(), String> {
|
||
let out = Command::new("osascript")
|
||
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
|
||
.output()
|
||
.map_err(|e| e.to_string())?;
|
||
if !out.status.success() {
|
||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||
}
|
||
let deadline = Instant::now() + Duration::from_secs(30);
|
||
while is_running(&project) {
|
||
if Instant::now() > deadline {
|
||
return Err(format!("{project} hat sich nach 30 s nicht beendet"));
|
||
}
|
||
std::thread::sleep(Duration::from_millis(250));
|
||
}
|
||
open_project(&project)
|
||
}
|
||
|
||
// ---------- OAuth-Flow ----------
|
||
|
||
fn pct(s: &str) -> String {
|
||
let mut out = String::new();
|
||
for b in s.bytes() {
|
||
match b {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||
out.push(b as char)
|
||
}
|
||
_ => out.push_str(&format!("%{:02X}", b)),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn random_urlsafe(len: usize) -> String {
|
||
let mut buf = vec![0u8; len];
|
||
getrandom::getrandom(&mut buf).expect("getrandom");
|
||
URL_SAFE_NO_PAD.encode(buf)
|
||
}
|
||
|
||
fn now_ms() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.expect("Systemzeit")
|
||
.as_millis() as u64
|
||
}
|
||
|
||
/// Schreibt die Credential-Datei im Claude-Code-Format (0600).
|
||
fn write_credentials_file(
|
||
cred_path: &PathBuf,
|
||
creds: &serde_json::Value,
|
||
) -> Result<(), String> {
|
||
let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?;
|
||
write_secret_file(cred_path, &(raw + "\n"))
|
||
}
|
||
|
||
/// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei.
|
||
fn exchange_and_store(
|
||
body: serde_json::Value,
|
||
cred_path: &PathBuf,
|
||
) -> Result<(), String> {
|
||
let resp = ureq::post(TOKEN_URL)
|
||
.set("Content-Type", "application/json")
|
||
.send_json(body);
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(ureq::Error::Status(code, r)) => {
|
||
let txt = r.into_string().unwrap_or_default();
|
||
return Err(format!("Token-Endpoint {code}: {txt}"));
|
||
}
|
||
Err(e) => return Err(e.to_string()),
|
||
};
|
||
let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?;
|
||
|
||
let access = v["access_token"]
|
||
.as_str()
|
||
.ok_or("access_token fehlt in Antwort")?;
|
||
let refresh = v["refresh_token"]
|
||
.as_str()
|
||
.ok_or("refresh_token fehlt in Antwort")?;
|
||
let expires_in = v["expires_in"].as_u64().unwrap_or(0);
|
||
let scopes: Vec<String> = v["scope"]
|
||
.as_str()
|
||
.unwrap_or(SCOPES)
|
||
.split_whitespace()
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
|
||
let creds = serde_json::json!({
|
||
"claudeAiOauth": {
|
||
"accessToken": access,
|
||
"refreshToken": refresh,
|
||
"expiresAt": now_ms() + expires_in * 1000,
|
||
"scopes": scopes,
|
||
}
|
||
});
|
||
write_credentials_file(cred_path, &creds)
|
||
}
|
||
|
||
/// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und
|
||
/// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt
|
||
/// die Tokens nach `cred_path`.
|
||
fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> {
|
||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
|
||
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||
let redirect_uri = format!("http://localhost:{port}/callback");
|
||
|
||
let verifier = random_urlsafe(32);
|
||
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||
let state = random_urlsafe(32);
|
||
|
||
let auth_url = format!(
|
||
"{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\
|
||
&redirect_uri={ru}&scope={sc}&code_challenge={challenge}\
|
||
&code_challenge_method=S256&state={st}",
|
||
ru = pct(&redirect_uri),
|
||
sc = pct(SCOPES),
|
||
st = pct(&state),
|
||
);
|
||
|
||
Command::new("open")
|
||
.arg(&auth_url)
|
||
.status()
|
||
.map_err(|e| format!("Browser öffnen: {e}"))?;
|
||
|
||
let (code, got_state) = wait_for_callback(&listener)?;
|
||
if got_state != state {
|
||
return Err("state stimmt nicht überein".into());
|
||
}
|
||
|
||
let body = serde_json::json!({
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
"state": got_state,
|
||
"client_id": CLIENT_ID,
|
||
"redirect_uri": redirect_uri,
|
||
"code_verifier": verifier,
|
||
});
|
||
exchange_and_store(body, cred_path)
|
||
}
|
||
|
||
/// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`.
|
||
fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> {
|
||
let deadline = Instant::now() + Duration::from_secs(180);
|
||
loop {
|
||
if Instant::now() > deadline {
|
||
return Err("Login-Timeout (kein Redirect nach 180 s)".into());
|
||
}
|
||
match listener.accept() {
|
||
Ok((mut stream, _)) => {
|
||
let mut reader = BufReader::new(&stream);
|
||
let mut line = String::new();
|
||
reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
||
// "GET /callback?code=...&state=... HTTP/1.1"
|
||
let target = line.split_whitespace().nth(1).unwrap_or("");
|
||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||
let mut code = String::new();
|
||
let mut state = String::new();
|
||
for pair in query.split('&') {
|
||
if let Some(v) = pair.strip_prefix("code=") {
|
||
code = pct_decode(v);
|
||
} else if let Some(v) = pair.strip_prefix("state=") {
|
||
state = pct_decode(v);
|
||
}
|
||
}
|
||
let ok = !code.is_empty();
|
||
let body = if ok {
|
||
"Anmeldung erfolgreich. Fenster kann geschlossen werden."
|
||
} else {
|
||
"Kein Code im Redirect."
|
||
};
|
||
let _ = stream.write_all(
|
||
format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\
|
||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body
|
||
)
|
||
.as_bytes(),
|
||
);
|
||
if ok {
|
||
return Ok((code, state));
|
||
}
|
||
// Nebenanfrage (z. B. favicon) ignorieren und weiter warten.
|
||
}
|
||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
Err(e) => return Err(e.to_string()),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn pct_decode(s: &str) -> String {
|
||
let bytes = s.as_bytes();
|
||
let mut out = Vec::new();
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
match bytes[i] {
|
||
b'%' if i + 2 < bytes.len() => {
|
||
let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%');
|
||
out.push(h);
|
||
i += 3;
|
||
}
|
||
b'+' => {
|
||
out.push(b' ');
|
||
i += 1;
|
||
}
|
||
c => {
|
||
out.push(c);
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
String::from_utf8_lossy(&out).into_owned()
|
||
}
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
let mut args = std::env::args().skip(1);
|
||
let builder = match args.next().as_deref() {
|
||
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
|
||
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
|
||
Some("--terminal") => {
|
||
terminal_builder(args.next().expect("--terminal braucht einen Projektnamen"))
|
||
}
|
||
_ => main_builder(),
|
||
};
|
||
builder
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|
||
|
||
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
|
||
fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||
use tauri::Manager;
|
||
|
||
tauri::Builder::default()
|
||
.plugin(tauri_plugin_autostart::init(
|
||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||
None,
|
||
))
|
||
.setup(|app| {
|
||
if cfg!(debug_assertions) {
|
||
app.handle().plugin(
|
||
tauri_plugin_log::Builder::default()
|
||
.level(log::LevelFilter::Info)
|
||
.build(),
|
||
)?;
|
||
}
|
||
|
||
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
|
||
#[cfg(target_os = "macos")]
|
||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||
|
||
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
|
||
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
||
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
|
||
.title("AI Control")
|
||
.inner_size(800.0, 600.0)
|
||
.visible(false)
|
||
.build()?;
|
||
|
||
let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?;
|
||
let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
||
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
|
||
tauri::tray::TrayIconBuilder::with_id("main")
|
||
.icon(app.default_window_icon().unwrap().clone())
|
||
.menu(&menu)
|
||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||
"open" => {
|
||
let w = app.get_webview_window("main").unwrap();
|
||
w.show().unwrap();
|
||
w.set_focus().unwrap();
|
||
}
|
||
"quit" => app.exit(0),
|
||
_ => {}
|
||
})
|
||
.build(app)?;
|
||
|
||
Ok(())
|
||
})
|
||
// Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü.
|
||
.on_window_event(|window, event| {
|
||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||
api.prevent_close();
|
||
window.hide().unwrap();
|
||
}
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
list_projects,
|
||
create_project,
|
||
delete_project,
|
||
list_pools,
|
||
create_oauth_pool,
|
||
create_apikey_pool,
|
||
delete_pool,
|
||
assign_pool,
|
||
unassign_pool,
|
||
start_project,
|
||
stop_project,
|
||
restart_project,
|
||
oauth_login,
|
||
oauth_refresh,
|
||
set_apikey,
|
||
terminal::open_terminal
|
||
])
|
||
}
|
||
|
||
/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt
|
||
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
|
||
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||
tauri::Builder::default()
|
||
.manage(terminal::Terminals::default())
|
||
.setup(move |app| {
|
||
terminal::build_window(app.handle(), &project)?;
|
||
Ok(())
|
||
})
|
||
// Fenster zu → PTY-Session abräumen; danach endet der Prozess.
|
||
.on_window_event(|window, event| {
|
||
if let tauri::WindowEvent::Destroyed = event {
|
||
terminal::close(window);
|
||
}
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
terminal::term_start,
|
||
terminal::term_log,
|
||
terminal::term_write,
|
||
terminal::term_resize,
|
||
// Pool-Chip im Terminal-Header braucht die Projektliste auch hier.
|
||
list_projects
|
||
])
|
||
}
|
||
|
||
// ---------- Tests ----------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
|
||
static N: AtomicU32 = AtomicU32::new(0);
|
||
|
||
/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
|
||
/// echten System.
|
||
fn tmp_paths() -> Paths {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"ai-control-test-{}-{}",
|
||
std::process::id(),
|
||
N.fetch_add(1, Ordering::SeqCst)
|
||
));
|
||
fs::create_dir_all(dir.join("claude-projects")).unwrap();
|
||
Paths { home: dir }
|
||
}
|
||
|
||
fn dummy_creds() -> serde_json::Value {
|
||
serde_json::json!({
|
||
"claudeAiOauth": {
|
||
"accessToken": "at-test",
|
||
"refreshToken": "rt-test",
|
||
"expiresAt": 1_999_999_999_000u64,
|
||
"scopes": ["user:inference"],
|
||
}
|
||
})
|
||
}
|
||
|
||
fn make_oauth_pool(p: &Paths, name: &str) {
|
||
create_oauth_pool_in(p, name, |cred| {
|
||
write_credentials_file(cred, &dummy_creds())
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
fn mode(path: &PathBuf) -> u32 {
|
||
fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||
}
|
||
|
||
// -- Pool anlegen --
|
||
|
||
#[test]
|
||
fn apikey_pool_anlegen() {
|
||
let p = tmp_paths();
|
||
create_apikey_pool_in(&p, "kunde", "sk-test-123").unwrap();
|
||
let dir = p.pool_dir("kunde");
|
||
|
||
let pool: Pool =
|
||
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
||
assert_eq!(pool.name, "kunde");
|
||
assert_eq!(pool.credential_type, "apikey");
|
||
|
||
let key_path = dir.join(APIKEY_FILE);
|
||
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
|
||
assert_eq!(mode(&key_path), 0o600);
|
||
|
||
let settings: serde_json::Value =
|
||
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
|
||
.unwrap();
|
||
assert_eq!(
|
||
settings["apiKeyHelper"].as_str().unwrap(),
|
||
format!("cat '{}'", key_path.display())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_pool_anlegen() {
|
||
let p = tmp_paths();
|
||
make_oauth_pool(&p, "privat");
|
||
let dir = p.pool_dir("privat");
|
||
|
||
let pool: Pool =
|
||
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
|
||
assert_eq!(pool.credential_type, "oauth");
|
||
|
||
let cred_path = dir.join(CREDENTIALS_FILE);
|
||
let creds: serde_json::Value =
|
||
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test");
|
||
assert_eq!(mode(&cred_path), 0o600);
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_pool_login_fehlschlag_legt_nichts_an() {
|
||
let p = tmp_paths();
|
||
let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into()));
|
||
assert!(err.is_err());
|
||
assert!(!p.pool_dir("privat").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn pool_doppelt_anlegen_scheitert() {
|
||
let p = tmp_paths();
|
||
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||
let err = create_apikey_pool_in(&p, "kunde", "sk-2").unwrap_err();
|
||
assert!(err.contains("existiert bereits"));
|
||
}
|
||
|
||
#[test]
|
||
fn pool_leerer_key_scheitert() {
|
||
let p = tmp_paths();
|
||
assert!(create_apikey_pool_in(&p, "kunde", " ").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn pool_name_mit_slash_scheitert() {
|
||
let p = tmp_paths();
|
||
assert!(create_apikey_pool_in(&p, "a/b", "sk-1").is_err());
|
||
}
|
||
|
||
// -- Pool ändern --
|
||
|
||
#[test]
|
||
fn apikey_aendern() {
|
||
let p = tmp_paths();
|
||
create_apikey_pool_in(&p, "kunde", "sk-alt").unwrap();
|
||
set_apikey_in(&p, "kunde", "sk-neu").unwrap();
|
||
let key_path = p.pool_dir("kunde").join(APIKEY_FILE);
|
||
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n");
|
||
assert_eq!(mode(&key_path), 0o600);
|
||
}
|
||
|
||
#[test]
|
||
fn apikey_aendern_auf_oauth_pool_scheitert() {
|
||
let p = tmp_paths();
|
||
make_oauth_pool(&p, "privat");
|
||
assert!(set_apikey_in(&p, "privat", "sk-1").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_relogin_ueberschreibt_credentials() {
|
||
let p = tmp_paths();
|
||
make_oauth_pool(&p, "privat");
|
||
let cred_path = oauth_credential_path(&p, "privat").unwrap();
|
||
let mut neu = dummy_creds();
|
||
neu["claudeAiOauth"]["accessToken"] = "at-neu".into();
|
||
write_credentials_file(&cred_path, &neu).unwrap();
|
||
let creds: serde_json::Value =
|
||
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
|
||
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu");
|
||
}
|
||
|
||
#[test]
|
||
fn oauth_credential_path_auf_apikey_pool_scheitert() {
|
||
let p = tmp_paths();
|
||
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||
assert!(oauth_credential_path(&p, "kunde").is_err());
|
||
}
|
||
|
||
// -- Pool löschen --
|
||
|
||
#[test]
|
||
fn pool_loeschen() {
|
||
let p = tmp_paths();
|
||
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||
delete_pool_in(&p, "kunde").unwrap();
|
||
assert!(!p.pool_dir("kunde").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn pool_loeschen_mit_zuordnung_scheitert() {
|
||
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());
|
||
}
|
||
|
||
#[test]
|
||
fn pool_loeschen_unbekannt_scheitert() {
|
||
let p = tmp_paths();
|
||
assert!(delete_pool_in(&p, "gibtsnicht").is_err());
|
||
}
|
||
|
||
// -- Projekt zuordnen / rausnehmen / wechseln --
|
||
|
||
#[test]
|
||
fn projekt_zuordnen() {
|
||
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 cfg: ProjectConfig =
|
||
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
|
||
.unwrap();
|
||
assert_eq!(cfg.pool, "kunde");
|
||
|
||
let pools = list_pools_in(&p).unwrap();
|
||
assert_eq!(pools[0].projects, vec!["proj"]);
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_zuordnen_pool_fehlt_scheitert() {
|
||
let p = tmp_paths();
|
||
create_project_in(&p, "proj").unwrap();
|
||
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_rausnehmen() {
|
||
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();
|
||
unassign_pool_in(&p, "proj").unwrap();
|
||
assert!(!p.project_config("proj").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
|
||
let p = tmp_paths();
|
||
create_project_in(&p, "proj").unwrap();
|
||
assert!(unassign_pool_in(&p, "proj").is_err());
|
||
}
|
||
|
||
/// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen,
|
||
/// der Credential-Typ ist ihm egal.
|
||
#[test]
|
||
fn pool_wechseln_typ_ist_projekt_egal() {
|
||
let p = tmp_paths();
|
||
make_oauth_pool(&p, "privat");
|
||
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
|
||
create_project_in(&p, "proj").unwrap();
|
||
|
||
assign_pool_in(&p, "proj", "privat").unwrap();
|
||
assign_pool_in(&p, "proj", "kunde").unwrap();
|
||
|
||
let raw = fs::read_to_string(p.project_config("proj")).unwrap();
|
||
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||
assert_eq!(cfg, serde_json::json!({ "pool": "kunde" }));
|
||
}
|
||
|
||
// -- Projekt anlegen / löschen --
|
||
|
||
#[test]
|
||
fn projekt_anlegen() {
|
||
let p = tmp_paths();
|
||
create_project_in(&p, "proj").unwrap();
|
||
assert!(p.projects_dir().join("proj").join(".claude").is_dir());
|
||
|
||
let projects = list_projects_in(&p).unwrap();
|
||
assert_eq!(projects.len(), 1);
|
||
assert_eq!(projects[0].name, "proj");
|
||
assert_eq!(projects[0].pool, None);
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_doppelt_anlegen_scheitert() {
|
||
let p = tmp_paths();
|
||
create_project_in(&p, "proj").unwrap();
|
||
assert!(create_project_in(&p, "proj").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_loeschen() {
|
||
let p = tmp_paths();
|
||
create_project_in(&p, "proj").unwrap();
|
||
delete_project_in(&p, "proj").unwrap();
|
||
assert!(!p.projects_dir().join("proj").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn projekt_loeschen_unbekannt_scheitert() {
|
||
let p = tmp_paths();
|
||
assert!(delete_project_in(&p, "gibtsnicht").is_err());
|
||
}
|
||
}
|