This commit is contained in:
marcus.hinz
2026-07-03 20:46:26 +02:00
parent 8b9eeb44e2
commit 70e4898259
15 changed files with 1876 additions and 373 deletions
+189 -7
View File
@@ -79,12 +79,17 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
name = "app"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"getrandom 0.2.17",
"log",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-log",
"ureq",
]
[[package]]
@@ -122,6 +127,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -670,13 +686,33 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
@@ -687,7 +723,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -814,7 +850,7 @@ dependencies = [
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -2529,6 +2565,17 @@ dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -2632,6 +2679,20 @@ dependencies = [
"web-sys",
]
[[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"
@@ -2693,6 +2754,41 @@ 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"
@@ -3099,6 +3195,12 @@ 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"
@@ -3237,7 +3339,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3287,7 +3389,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -3357,6 +3459,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
@@ -3804,7 +3920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@@ -3890,6 +4006,30 @@ 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"
@@ -4151,6 +4291,24 @@ 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"
@@ -4381,6 +4539,15 @@ 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"
@@ -4562,6 +4729,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -4594,7 +4770,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dom_query",
"dpi",
"dunce",
@@ -4722,6 +4898,12 @@ 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"
+6 -1
View File
@@ -21,5 +21,10 @@ tauri-build = { version = "2.6.3", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri = { version = "2.11.3", features = ["tray-icon"] }
tauri-plugin-log = "2"
tauri-plugin-autostart = "2"
ureq = { version = "2", features = ["json"] }
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
+2 -1
View File
@@ -6,6 +6,7 @@
"main"
],
"permissions": [
"core:default"
"core:default",
"autostart:default"
]
}
+950
View File
@@ -1,6 +1,636 @@
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 {
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,
}
}
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 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() {
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(
@@ -9,8 +639,328 @@ pub fn run() {
.build(),
)?;
}
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
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(())
})
// Fenster 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,
restart_project,
oauth_login,
oauth_refresh,
set_apikey
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// ---------- 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());
}
}
+2 -1
View File
@@ -16,7 +16,8 @@
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
"fullscreen": false,
"visible": false
}
],
"security": {