Pools initialisieren, Pool-Guard raus, Löschschutz bei laufenden Sessions

- pool-guard-Hook aus Projekt-Scaffold und allen bestehenden Projekten entfernt
- neue Pools: settings.json mit aufgeräumten UI-Defaults
  (promptSuggestionEnabled/awaySummaryEnabled aus) + CLAUDE.md
- delete_pool bricht bei laufendem zugeordnetem Projekt ab; Übersicht sperrt
  den Delete-Button und zeigt den Grund per Hover-Pop (WKWebView-tauglich)
- Running-Erkennung zentralisiert (running_projects_using_pool),
  list_pools liefert pro Pool die laufenden Projekte
- Todo-Umschalter im Projekt-Wizard/Settings
- ungenutzte Dependencies ureq/getrandom entfernt
This commit is contained in:
marcus.hinz
2026-07-04 17:21:47 +02:00
parent 42d975874e
commit 20fc8474d2
7 changed files with 381 additions and 454 deletions
-114
View File
@@ -33,7 +33,6 @@ name = "ai-control"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"log",
"objc2",
"objc2-app-kit",
@@ -47,7 +46,6 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-log",
"ureq",
]
[[package]]
@@ -2828,20 +2826,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -2903,41 +2887,6 @@ dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3371,12 +3320,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4225,30 +4168,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4510,24 +4429,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4758,15 +4659,6 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5191,12 +5083,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.4"
-2
View File
@@ -25,10 +25,8 @@ tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
ureq = { version = "2", features = ["json"] }
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
portable-pty = "0.9"
[target.'cfg(target_os = "macos")'.dependencies]
+271 -303
View File
@@ -1,25 +1,18 @@
mod terminal;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::io::{BufRead, BufReader};
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";
/// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
/// die App speichert keine Tokens.
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
@@ -101,6 +94,15 @@ fn is_running(project: &str) -> bool {
ghostty_running(project) || !terminal_pids(project).is_empty()
}
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
/// Lauf-Erkennung wie die Projektliste (`is_running`).
fn running_projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
Ok(projects_using_pool(paths, pool)?
.into_iter()
.filter(|p| is_running(p))
.collect())
}
fn ghostty_running(project: &str) -> bool {
let out = Command::new("lsappinfo")
.args(["find", &format!("bundleid={}", bundle_id(project))])
@@ -164,6 +166,101 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
Ok(projects)
}
// ---------- Feature: Todoliste ----------
/// Muster robotunits: Datei im Projekt-Root, per SessionStart-Hook (jq)
/// als additionalContext in jede Session injiziert.
const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
fn todo_hook_command(paths: &Paths, name: &str) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
paths.projects_dir().join(name).join(TODO_FILE).display()
)
}
fn settings_path(paths: &Paths, name: &str) -> PathBuf {
paths
.projects_dir()
.join(name)
.join(".claude")
.join("settings.json")
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
group["hooks"]
.as_array()
.map(|hs| {
hs.iter().any(|h| {
h["command"]
.as_str()
.map_or(false, |c| c.contains(TODO_FILE))
})
})
.unwrap_or(false)
}
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(paths, name);
if !sp.is_file() {
return Ok(false);
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
Ok(
v["hooks"]["SessionStart"]
.as_array()
.map(|a| a.iter().any(hook_is_todo))
.unwrap_or(false),
)
}
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let sp = settings_path(paths, name);
if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display()));
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let hooks = root
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("hooks ist kein Objekt")?;
let session_start = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("SessionStart ist kein Array")?;
session_start.retain(|g| !hook_is_todo(g));
if enabled {
session_start.insert(
0,
serde_json::json!({
"hooks": [
{ "type": "command", "command": todo_hook_command(paths, name) }
]
}),
);
let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// "~/x" relativ zum Home auflösen; alles andere unverändert.
fn expand_home(paths: &Paths, p: &str) -> PathBuf {
match p.strip_prefix("~/") {
@@ -174,7 +271,7 @@ fn expand_home(paths: &Paths, p: &str) -> PathBuf {
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// pool-guard-Hook), ai-control.json mit Pool/Terminal-Config. Kein git init —
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
/// der Ordner ist Teil des einen claude-projects-Repos.
fn create_project_full_in(
paths: &Paths,
@@ -183,6 +280,7 @@ fn create_project_full_in(
work_dir: Option<&str>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
@@ -215,26 +313,12 @@ fn create_project_full_in(
allow.insert(0, format!("Edit({wd}/**)"));
additional.push(wd.to_string());
}
let pool_guard = paths
.projects_dir()
.join("robotunits")
.join("bin")
.join("pool-guard");
let settings = serde_json::json!({
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
"permissions": {
"allow": allow,
"additionalDirectories": additional,
},
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": pool_guard.to_string_lossy() }
]
}
]
}
});
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
@@ -243,9 +327,13 @@ fn create_project_full_in(
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
}
if todo {
set_todo_in(paths, name, true)?;
}
Ok(())
}
#[cfg(test)]
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
@@ -381,6 +469,9 @@ struct PoolInfo {
#[serde(rename = "credentialType")]
credential_type: String,
projects: Vec<String>,
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
/// Projektliste). Der Löschen-Dialog sperrt darauf.
running: Vec<String>,
#[serde(rename = "hasCredentials")]
has_credentials: bool,
}
@@ -405,8 +496,11 @@ fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
.unwrap_or(false),
_ => true,
};
let projects = projects_using_pool(paths, &pool.name)?;
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
pools.push(PoolInfo {
projects: projects_using_pool(paths, &pool.name)?,
projects,
running,
name: pool.name,
credential_type: pool.credential_type,
has_credentials,
@@ -443,8 +537,37 @@ fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
}
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
/// Die Prompt-Vorschläge/Rückkehr-Zusammenfassung werden abgeschaltet — sonst
/// erscheint oben die Vorschlagstabelle.
fn init_pool_config(
dir: &PathBuf,
extra: serde_json::Value,
) -> Result<(), String> {
let mut settings = serde_json::json!({
"promptSuggestionEnabled": false,
"awaySummaryEnabled": false,
});
let base = settings.as_object_mut().unwrap();
if let Some(obj) = extra.as_object() {
for (k, v) in obj {
base.insert(k.clone(), v.clone());
}
}
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
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())?;
let claude_md = dir.join("CLAUDE.md");
if !claude_md.is_file() {
fs::write(&claude_md, "").map_err(|e| e.to_string())?;
}
Ok(())
}
/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit
/// apiKeyHelper auf diese Datei, pool.json.
/// apiKeyHelper auf diese Datei, CLAUDE.md, 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();
@@ -453,29 +576,25 @@ fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), Str
}
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())?;
init_pool_config(
&dir,
serde_json::json!({ "apiKeyHelper": format!("cat '{}'", key_path.display()) }),
)?;
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> {
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
let dir = check_new_pool(paths, name)?;
login(&dir.join(CREDENTIALS_FILE))?;
init_pool_config(&dir, serde_json::json!({}))?;
write_pool_json(&dir, name, "oauth")
}
/// 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).
/// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte
/// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den
/// Schutz gegen laufende Sessions setzt der delete_pool-Command davor.
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() {
@@ -500,12 +619,12 @@ fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> {
write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n"))
}
fn oauth_credential_path(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), 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))
Ok(())
}
// ---------- Verbrauch ----------
@@ -707,6 +826,7 @@ fn create_project_full(
work_dir: Option<String>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> {
create_project_full_in(
&Paths::real(),
@@ -715,9 +835,20 @@ fn create_project_full(
work_dir.as_deref(),
create_work_dir,
terminal,
todo,
)
}
#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
}
#[tauri::command]
fn set_todo(project: String, enabled: bool) -> Result<(), String> {
set_todo_in(&Paths::real(), &project, enabled)
}
#[tauri::command]
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
if is_running(&name) {
@@ -738,7 +869,7 @@ fn list_pools() -> Result<Vec<PoolInfo>, String> {
#[tauri::command]
fn create_oauth_pool(name: String) -> Result<(), String> {
create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow)
create_oauth_pool_in(&Paths::real(), &name)
}
#[tauri::command]
@@ -748,7 +879,15 @@ fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
#[tauri::command]
fn delete_pool(name: String) -> Result<(), String> {
delete_pool_in(&Paths::real(), &name)
let paths = Paths::real();
let running = running_projects_using_pool(&paths, &name)?;
if !running.is_empty() {
return Err(format!(
"Pool wird noch benutzt — läuft: {}",
running.join(", ")
));
}
delete_pool_in(&paths, &name)
}
#[tauri::command]
@@ -803,19 +942,15 @@ 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).
/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
#[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real();
let cred_path = oauth_credential_path(&paths, &pool)?;
ensure_oauth_pool(&paths, &pool)?;
let running: Vec<String> = projects_using_pool(&paths, &pool)?
.into_iter()
.filter(|p| is_running(p))
.collect();
let running = running_projects_using_pool(&paths, &pool)?;
if !running.is_empty() {
return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
@@ -833,7 +968,7 @@ fn oauth_login(pool: String) -> Result<(), String> {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
run_oauth_flow(&cred_path)
Ok(())
}
fn open_project(project: &str) -> Result<(), String> {
@@ -908,205 +1043,6 @@ fn restart_project(project: String) -> Result<(), String> {
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() {
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
@@ -1214,6 +1150,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
assign_pool,
unassign_pool,
set_terminal_config,
todo_state,
set_todo,
usage_stats,
start_project,
stop_project,
@@ -1272,22 +1210,8 @@ mod tests {
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();
create_oauth_pool_in(p, name).unwrap();
}
fn mode(path: &PathBuf) -> u32 {
@@ -1330,19 +1254,31 @@ mod tests {
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);
// Kein Credentials-File — den Login macht claude selbst beim ersten Start.
assert!(!dir.join(".credentials.json").exists());
// Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
// damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
.unwrap();
assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false));
assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false));
assert!(dir.join("CLAUDE.md").is_file());
}
#[test]
fn oauth_pool_login_fehlschlag_legt_nichts_an() {
fn pool_init_erhaelt_bestehende_claude_md() {
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());
let dir = p.pool_dir("privat");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap();
// Anlegen darf die vorhandene CLAUDE.md nicht überschreiben.
// (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen)
init_pool_config(&dir, serde_json::json!({})).unwrap();
assert_eq!(
fs::read_to_string(dir.join("CLAUDE.md")).unwrap(),
"meine Vorgaben\n"
);
}
#[test]
@@ -1385,23 +1321,12 @@ mod tests {
}
#[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() {
fn ensure_oauth_pool_auf_apikey_scheitert() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
assert!(oauth_credential_path(&p, "kunde").is_err());
assert!(ensure_oauth_pool(&p, "kunde").is_err());
make_oauth_pool(&p, "privat");
assert!(ensure_oauth_pool(&p, "privat").is_ok());
}
// -- Pool löschen --
@@ -1431,6 +1356,7 @@ mod tests {
icon: None,
title: Some("Neu".into()),
},
true,
)
.unwrap();
@@ -1458,16 +1384,57 @@ mod tests {
"Edit(~/claude-projects/neu/**)"
);
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
assert!(dir.join(TODO_FILE).is_file());
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.ends_with("bin/pool-guard"));
.contains(TODO_FILE));
}
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, "proj").unwrap());
set_todo_in(&p, "proj", true).unwrap();
assert!(todo_state_in(&p, "proj").unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, "proj", true).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p, "proj")).unwrap(),
)
.unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, "proj", false).unwrap();
assert!(!todo_state_in(&p, "proj").unwrap());
assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
}
#[test]
fn todo_ohne_settings_scheitert() {
let p = tmp_paths();
create_project_in(&p, "alt").unwrap();
assert!(set_todo_in(&p, "alt", true).is_err());
assert!(!todo_state_in(&p, "alt").unwrap());
}
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default()).unwrap();
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json
@@ -1485,7 +1452,7 @@ mod tests {
let p = tmp_paths();
create_project_in(&p, "neu").unwrap();
let err =
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default())
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false)
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
@@ -1500,6 +1467,7 @@ mod tests {
Some("~/projects/gibtsnicht"),
false,
TerminalConfig::default(),
false,
)
.unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt"));
@@ -1737,7 +1705,7 @@ mod tests {
#[test]
fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1747,7 +1715,7 @@ mod tests {
#[test]
fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
@@ -1757,7 +1725,7 @@ mod tests {
#[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err());
@@ -1766,7 +1734,7 @@ mod tests {
#[test]
fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default())
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer