Files
ai-control/src-tauri/src/lib.rs
T
marcus.hinz f7473797a9 Eingebautes Terminal (xterm.js + portable-pty) und Start/Beenden-Button
- 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
2026-07-04 10:00:12 +02:00

1067 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}