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" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"getrandom 0.2.17",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -47,7 +46,6 @@ dependencies = [
"tauri-plugin-autostart", "tauri-plugin-autostart",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-log", "tauri-plugin-log",
"ureq",
] ]
[[package]] [[package]]
@@ -2828,20 +2826,6 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "rkyv" name = "rkyv"
version = "0.7.46" version = "0.7.46"
@@ -2903,41 +2887,6 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3371,12 +3320,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -4225,30 +4168,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4510,24 +4429,6 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4758,15 +4659,6 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5191,12 +5083,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" 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-log = "2"
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
ureq = { version = "2", features = ["json"] }
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
getrandom = "0.2"
portable-pty = "0.9" portable-pty = "0.9"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
+271 -303
View File
@@ -1,25 +1,18 @@
mod terminal; mod terminal;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; 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 /// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt. /// CLAUDE_CONFIG_DIR. oauth-Credentials verwaltet claude selbst (Keychain);
const CREDENTIALS_FILE: &str = ".credentials.json"; /// die App speichert keine Tokens.
const APIKEY_FILE: &str = "apikey"; const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json"; const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.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() 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 { fn ghostty_running(project: &str) -> bool {
let out = Command::new("lsappinfo") let out = Command::new("lsappinfo")
.args(["find", &format!("bundleid={}", bundle_id(project))]) .args(["find", &format!("bundleid={}", bundle_id(project))])
@@ -164,6 +166,101 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
Ok(projects) 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. /// "~/x" relativ zum Home auflösen; alles andere unverändert.
fn expand_home(paths: &Paths, p: &str) -> PathBuf { fn expand_home(paths: &Paths, p: &str) -> PathBuf {
match p.strip_prefix("~/") { 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 /// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions, /// (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. /// der Ordner ist Teil des einen claude-projects-Repos.
fn create_project_full_in( fn create_project_full_in(
paths: &Paths, paths: &Paths,
@@ -183,6 +280,7 @@ fn create_project_full_in(
work_dir: Option<&str>, work_dir: Option<&str>,
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> { ) -> Result<(), String> {
check_name(name)?; check_name(name)?;
let dir = paths.projects_dir().join(name); let dir = paths.projects_dir().join(name);
@@ -215,26 +313,12 @@ fn create_project_full_in(
allow.insert(0, format!("Edit({wd}/**)")); allow.insert(0, format!("Edit({wd}/**)"));
additional.push(wd.to_string()); additional.push(wd.to_string());
} }
let pool_guard = paths
.projects_dir()
.join("robotunits")
.join("bin")
.join("pool-guard");
let settings = serde_json::json!({ let settings = serde_json::json!({
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"), "autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
"permissions": { "permissions": {
"allow": allow, "allow": allow,
"additionalDirectories": additional, "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())?; 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())?; 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() { if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?; write_project_config_in(paths, name, &cfg)?;
} }
if todo {
set_todo_in(paths, name, true)?;
}
Ok(()) Ok(())
} }
#[cfg(test)]
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> { fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
check_name(name)?; check_name(name)?;
let dir = paths.projects_dir().join(name); let dir = paths.projects_dir().join(name);
@@ -381,6 +469,9 @@ struct PoolInfo {
#[serde(rename = "credentialType")] #[serde(rename = "credentialType")]
credential_type: String, credential_type: String,
projects: Vec<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")] #[serde(rename = "hasCredentials")]
has_credentials: bool, has_credentials: bool,
} }
@@ -405,8 +496,11 @@ fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
.unwrap_or(false), .unwrap_or(false),
_ => true, _ => true,
}; };
let projects = projects_using_pool(paths, &pool.name)?;
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
pools.push(PoolInfo { pools.push(PoolInfo {
projects: projects_using_pool(paths, &pool.name)?, projects,
running,
name: pool.name, name: pool.name,
credential_type: pool.credential_type, credential_type: pool.credential_type,
has_credentials, 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()) 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 /// 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> { fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> {
let dir = check_new_pool(paths, name)?; let dir = check_new_pool(paths, name)?;
let key = key.trim(); 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); let key_path = dir.join(APIKEY_FILE);
write_secret_file(&key_path, &format!("{key}\n"))?; write_secret_file(&key_path, &format!("{key}\n"))?;
let settings = serde_json::json!({ init_pool_config(
"apiKeyHelper": format!("cat '{}'", key_path.display()), &dir,
}); 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") write_pool_json(&dir, name, "apikey")
} }
/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den /// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
/// Pool-Ordner), dann nur bei Erfolg pool.json. /// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
fn create_oauth_pool_in( /// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
paths: &Paths, fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
name: &str,
login: impl FnOnce(&PathBuf) -> Result<(), String>,
) -> Result<(), String> {
let dir = check_new_pool(paths, name)?; 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") write_pool_json(&dir, name, "oauth")
} }
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt. /// Löscht einen Pool samt Ordner (inkl. Credentials). Zugeordnete Projekte
/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die /// verlieren die Zuordnung (Terminal-Einstellungen bleiben erhalten). Den
/// Zuordnung (Terminal-Einstellungen bleiben erhalten). /// Schutz gegen laufende Sessions setzt der delete_pool-Command davor.
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> { fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
let dir = paths.pool_dir(name); let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() { 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")) 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)?; let p = read_pool(paths, pool)?;
if p.credential_type != "oauth" { if p.credential_type != "oauth" {
return Err(format!("Pool {pool} ist kein oauth-Pool")); return Err(format!("Pool {pool} ist kein oauth-Pool"));
} }
Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE)) Ok(())
} }
// ---------- Verbrauch ---------- // ---------- Verbrauch ----------
@@ -707,6 +826,7 @@ fn create_project_full(
work_dir: Option<String>, work_dir: Option<String>,
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> { ) -> Result<(), String> {
create_project_full_in( create_project_full_in(
&Paths::real(), &Paths::real(),
@@ -715,9 +835,20 @@ fn create_project_full(
work_dir.as_deref(), work_dir.as_deref(),
create_work_dir, create_work_dir,
terminal, 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] #[tauri::command]
fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> { fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
if is_running(&name) { if is_running(&name) {
@@ -738,7 +869,7 @@ fn list_pools() -> Result<Vec<PoolInfo>, String> {
#[tauri::command] #[tauri::command]
fn create_oauth_pool(name: String) -> Result<(), String> { 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] #[tauri::command]
@@ -748,7 +879,15 @@ fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn delete_pool(name: String) -> Result<(), String> { 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] #[tauri::command]
@@ -803,19 +942,15 @@ fn keychain_status(pool: String) -> Result<bool, String> {
keychain_entry_exists(&keychain_service(&Paths::real(), &pool)) keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
} }
/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher /// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten /// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
/// Session-Start auch greift (claude importiert die frische /// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
/// .credentials.json und legt den Eintrag neu an).
#[tauri::command] #[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> { fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real(); 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)? let running = running_projects_using_pool(&paths, &pool)?;
.into_iter()
.filter(|p| is_running(p))
.collect();
if !running.is_empty() { if !running.is_empty() {
return Err(format!( return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}", "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()); return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
} }
} }
run_oauth_flow(&cred_path) Ok(())
} }
fn open_project(project: &str) -> Result<(), String> { fn open_project(project: &str) -> Result<(), String> {
@@ -908,205 +1043,6 @@ fn restart_project(project: String) -> Result<(), String> {
open_project(&project) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
@@ -1214,6 +1150,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
assign_pool, assign_pool,
unassign_pool, unassign_pool,
set_terminal_config, set_terminal_config,
todo_state,
set_todo,
usage_stats, usage_stats,
start_project, start_project,
stop_project, stop_project,
@@ -1272,22 +1210,8 @@ mod tests {
Paths { home: dir } 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) { fn make_oauth_pool(p: &Paths, name: &str) {
create_oauth_pool_in(p, name, |cred| { create_oauth_pool_in(p, name).unwrap();
write_credentials_file(cred, &dummy_creds())
})
.unwrap();
} }
fn mode(path: &PathBuf) -> u32 { 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(); serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
assert_eq!(pool.credential_type, "oauth"); assert_eq!(pool.credential_type, "oauth");
let cred_path = dir.join(CREDENTIALS_FILE); // Kein Credentials-File — den Login macht claude selbst beim ersten Start.
let creds: serde_json::Value = assert!(!dir.join(".credentials.json").exists());
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap(); // Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test"); // damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
assert_eq!(mode(&cred_path), 0o600); 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] #[test]
fn oauth_pool_login_fehlschlag_legt_nichts_an() { fn pool_init_erhaelt_bestehende_claude_md() {
let p = tmp_paths(); let p = tmp_paths();
let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into())); let dir = p.pool_dir("privat");
assert!(err.is_err()); fs::create_dir_all(&dir).unwrap();
assert!(!p.pool_dir("privat").exists()); 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] #[test]
@@ -1385,23 +1321,12 @@ mod tests {
} }
#[test] #[test]
fn oauth_relogin_ueberschreibt_credentials() { fn ensure_oauth_pool_auf_apikey_scheitert() {
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(); let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap(); 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 -- // -- Pool löschen --
@@ -1431,6 +1356,7 @@ mod tests {
icon: None, icon: None,
title: Some("Neu".into()), title: Some("Neu".into()),
}, },
true,
) )
.unwrap(); .unwrap();
@@ -1458,16 +1384,57 @@ mod tests {
"Edit(~/claude-projects/neu/**)" "Edit(~/claude-projects/neu/**)"
); );
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/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() .as_str()
.unwrap() .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] #[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() { fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths(); 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"); let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file()); assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json // ohne Pool und Terminal-Config entsteht keine ai-control.json
@@ -1485,7 +1452,7 @@ mod tests {
let p = tmp_paths(); let p = tmp_paths();
create_project_in(&p, "neu").unwrap(); create_project_in(&p, "neu").unwrap();
let err = 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(); .unwrap_err();
assert!(err.contains("existiert bereits")); assert!(err.contains("existiert bereits"));
} }
@@ -1500,6 +1467,7 @@ mod tests {
Some("~/projects/gibtsnicht"), Some("~/projects/gibtsnicht"),
false, false,
TerminalConfig::default(), TerminalConfig::default(),
false,
) )
.unwrap_err(); .unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt")); assert!(err.contains("Arbeitsverzeichnis fehlt"));
@@ -1737,7 +1705,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_laesst_arbeitsordner() { fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths(); 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(); .unwrap();
delete_project_in(&p, "proj", false).unwrap(); delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -1747,7 +1715,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_mit_arbeitsordner() { fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths(); 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(); .unwrap();
delete_project_in(&p, "proj", true).unwrap(); delete_project_in(&p, "proj", true).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -1757,7 +1725,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths(); 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(); .unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err()); assert!(delete_project_in(&p, "proj", true).is_err());
@@ -1766,7 +1734,7 @@ mod tests {
#[test] #[test]
fn projekt_arbeitsordner_auslesen() { fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths(); 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(); .unwrap();
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]); assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer // Projekt ohne settings.json → leer
+17 -9
View File
@@ -9,6 +9,7 @@ interface Pool {
name: string; name: string;
credentialType: string; credentialType: string;
projects: string[]; projects: string[];
running: string[];
hasCredentials: boolean; hasCredentials: boolean;
} }
@@ -206,9 +207,20 @@ onMounted(refresh);
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }} {{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
</button> </button>
</template> </template>
<button class="danger" :disabled="busy" @click="askDelete(p.name)"> <span class="tip-wrap" :class="{ 'hover-pop': p.running.length }">
{{ $t("pools.delete") }} <button
</button> class="danger"
:disabled="busy || p.running.length > 0"
@click="askDelete(p.name)"
>
{{ $t("pools.delete") }}
</button>
<span v-if="p.running.length" class="pop pop-right">
<span class="pop-text">
{{ $t("pools.deleteBlockedTooltip", { projects: p.running.join(", ") }) }}
</span>
</span>
</span>
</span> </span>
</td> </td>
</tr> </tr>
@@ -239,16 +251,13 @@ onMounted(refresh);
<p v-if="dialog.mode === 'oauth'" class="hint"> <p v-if="dialog.mode === 'oauth'" class="hint">
{{ $t("pools.oauthHint") }} {{ $t("pools.oauthHint") }}
</p> </p>
<p v-if="busy && dialog.mode === 'oauth'" class="hint busy">
{{ $t("pools.oauthWaiting") }}
</p>
<div class="actions"> <div class="actions">
<button type="button" :disabled="busy" @click="closeDialog"> <button type="button" :disabled="busy" @click="closeDialog">
{{ $t("pools.cancel") }} {{ $t("pools.cancel") }}
</button> </button>
<button type="submit" class="primary" :disabled="busy"> <button type="submit" class="primary" :disabled="busy">
{{ dialog.mode === "oauth" ? $t("pools.startLogin") : $t("pools.save") }} {{ dialog.mode === "oauth" ? $t("pools.createPool") : $t("pools.save") }}
</button> </button>
</div> </div>
</form> </form>
@@ -280,13 +289,12 @@ onMounted(refresh);
: $t("pools.reloginNoEntry") : $t("pools.reloginNoEntry")
}} }}
</p> </p>
<p v-if="busy" class="hint busy">{{ $t("pools.oauthWaiting") }}</p>
<div class="actions"> <div class="actions">
<button type="button" :disabled="busy" @click="reloginPool = null"> <button type="button" :disabled="busy" @click="reloginPool = null">
{{ $t("pools.cancel") }} {{ $t("pools.cancel") }}
</button> </button>
<button class="primary" :disabled="busy" @click="confirmRelogin"> <button class="primary" :disabled="busy" @click="confirmRelogin">
{{ $t("pools.startLogin") }} {{ $t("pools.reset") }}
</button> </button>
</div> </div>
</template> </template>
+32 -7
View File
@@ -110,17 +110,24 @@ interface TerminalSettings {
theme: string; theme: string;
icon: string | null; icon: string | null;
title: string; title: string;
todo: boolean;
} }
const settings = ref<TerminalSettings | null>(null); const settings = ref<TerminalSettings | null>(null);
function openSettings(p: Project) { async function openSettings(p: Project) {
settings.value = { try {
name: p.name, const todo = await invoke<boolean>("todo_state", { project: p.name });
theme: p.terminal.theme ?? "mocha", settings.value = {
icon: p.terminal.icon, name: p.name,
title: p.terminal.title ?? "", theme: p.terminal.theme ?? "mocha",
}; icon: p.terminal.icon,
title: p.terminal.title ?? "",
todo,
};
} catch (e) {
error.value = String(e);
}
} }
async function pickIcon() { async function pickIcon() {
@@ -142,6 +149,7 @@ async function saveSettings() {
icon: s.icon, icon: s.icon,
title: title === "" ? null : title, title: title === "" ? null : title,
}); });
await invoke("set_todo", { project: s.name, enabled: s.todo });
settings.value = null; settings.value = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -156,6 +164,7 @@ interface Wizard {
workDir: string | null; workDir: string | null;
title: string; title: string;
theme: string; theme: string;
todo: boolean;
} }
const wizard = ref<Wizard | null>(null); const wizard = ref<Wizard | null>(null);
@@ -169,6 +178,7 @@ function openWizard() {
workDir: null, workDir: null,
title: "", title: "",
theme: "mocha", theme: "mocha",
todo: false,
}; };
} }
@@ -201,6 +211,7 @@ async function createProject() {
icon: null, icon: null,
title: title === "" ? null : title, title: title === "" ? null : title,
}, },
todo: w.todo,
}); });
wizard.value = null; wizard.value = null;
await refresh(); await refresh();
@@ -378,6 +389,13 @@ onUnmounted(() => window.clearInterval(timer));
</option> </option>
</select> </select>
</label> </label>
<label class="field">
{{ $t("projects.todo") }}
<span class="checkline">
<input v-model="wizard.todo" type="checkbox" />
{{ $t("projects.todoDesc") }}
</span>
</label>
<p class="hint">{{ $t("projects.wizardHint") }}</p> <p class="hint">{{ $t("projects.wizardHint") }}</p>
<div class="actions"> <div class="actions">
<button type="button" @click="wizard = null"> <button type="button" @click="wizard = null">
@@ -453,6 +471,13 @@ onUnmounted(() => window.clearInterval(timer));
<span v-else class="icon-path">{{ $t("projects.defaultIcon") }}</span> <span v-else class="icon-path">{{ $t("projects.defaultIcon") }}</span>
</span> </span>
</label> </label>
<label class="field">
{{ $t("projects.todo") }}
<span class="checkline">
<input v-model="settings.todo" type="checkbox" />
{{ $t("projects.todoDesc") }}
</span>
</label>
<p class="hint">{{ $t("projects.appliesNextStart") }}</p> <p class="hint">{{ $t("projects.appliesNextStart") }}</p>
<div class="actions"> <div class="actions">
<button @click="settings = null">{{ $t("projects.cancel") }}</button> <button @click="settings = null">{{ $t("projects.cancel") }}</button>
+26 -18
View File
@@ -59,7 +59,7 @@ const de = {
chooseFolder: "Ordner wählen …", chooseFolder: "Ordner wählen …",
create: "Anlegen", create: "Anlegen",
wizardHint: wizardHint:
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json mit pool-guard-Hook und die Pool-/Terminal-Zuordnung.", "Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.",
delete: "Projekt löschen", delete: "Projekt löschen",
deleteRunning: "Läuft noch — erst beenden", deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen", deleteTitle: "{name} löschen",
@@ -67,6 +67,8 @@ const de = {
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.", "Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
deleteWorkDirs: "Arbeitsordner mitlöschen:", deleteWorkDirs: "Arbeitsordner mitlöschen:",
workDirsStay: "Die Arbeitsordner bleiben unangetastet.", workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
todo: "Todoliste",
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
}, },
pools: { pools: {
pool: "Pool", pool: "Pool",
@@ -74,7 +76,7 @@ const de = {
actions: "Aktionen", actions: "Aktionen",
newOauth: "+ oAuth", newOauth: "+ oAuth",
newApikey: "+ apiKey", newApikey: "+ apiKey",
relogin: "Neu anmelden", relogin: "Zurücksetzen",
changeKey: "Key ändern", changeKey: "Key ändern",
insertKey: "Key eintragen", insertKey: "Key eintragen",
delete: "Löschen", delete: "Löschen",
@@ -86,22 +88,24 @@ const de = {
name: "Name", name: "Name",
apiKey: "API-Key", apiKey: "API-Key",
oauthHint: oauthHint:
"Nach „Login starten“ öffnet sich der Browser zur Anmeldung gegen dein Abo.", "Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
oauthWaiting: "Warte auf die Anmeldung im Browser…",
cancel: "Abbrechen", cancel: "Abbrechen",
startLogin: "Login starten", createPool: "Anlegen",
reset: "Zurücksetzen",
save: "Speichern", save: "Speichern",
deletePool: "Pool löschen", deletePool: "Pool löschen",
deleteWarning: deleteWarning:
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.", "Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:", deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
reloginTitle: "Neu anmelden {name}", deleteBlockedTooltip:
"Löschen gesperrt — {projects} läuft gerade. Erst beenden.",
reloginTitle: "{name} zurücksetzen",
reloginWarning: reloginWarning:
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Danach öffnet sich der Browser zur Neuanmeldung.", "Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.",
reloginNoEntry: reloginNoEntry:
"Kein Schlüsselbund-Eintrag vorhanden — es erfolgt nur die Neuanmeldung im Browser.", "Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
reloginBlocked: reloginBlocked:
"Neuanmeldung ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:", "Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
}, },
}; };
@@ -164,7 +168,7 @@ const en: typeof de = {
chooseFolder: "Choose folder …", chooseFolder: "Choose folder …",
create: "Create", create: "Create",
wizardHint: wizardHint:
"Creates the project folder with memory/, sentinel .gitignore, settings.json with the pool-guard hook, and the pool/terminal assignment.", "Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.",
delete: "Delete project", delete: "Delete project",
deleteRunning: "Still running — stop it first", deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}", deleteTitle: "Delete {name}",
@@ -172,6 +176,8 @@ const en: typeof de = {
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.", "The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
deleteWorkDirs: "Also delete working folders:", deleteWorkDirs: "Also delete working folders:",
workDirsStay: "The working folders stay untouched.", workDirsStay: "The working folders stay untouched.",
todo: "Todo list",
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
}, },
pools: { pools: {
pool: "Pool", pool: "Pool",
@@ -179,7 +185,7 @@ const en: typeof de = {
actions: "Actions", actions: "Actions",
newOauth: "+ oAuth", newOauth: "+ oAuth",
newApikey: "+ apiKey", newApikey: "+ apiKey",
relogin: "Sign in again", relogin: "Reset",
changeKey: "Change key", changeKey: "Change key",
insertKey: "Insert key", insertKey: "Insert key",
delete: "Delete", delete: "Delete",
@@ -191,22 +197,24 @@ const en: typeof de = {
name: "Name", name: "Name",
apiKey: "API key", apiKey: "API key",
oauthHint: oauthHint:
"After “Start login” the browser opens to sign in against your subscription.", "The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
oauthWaiting: "Waiting for the sign-in in the browser…",
cancel: "Cancel", cancel: "Cancel",
startLogin: "Start login", createPool: "Create",
reset: "Reset",
save: "Save", save: "Save",
deletePool: "Delete pool", deletePool: "Delete pool",
deleteWarning: deleteWarning:
"Pool {name} and its credential file will be deleted. This cannot be undone.", "Pool {name} and its credential file will be deleted. This cannot be undone.",
deleteUnassigns: "These projects lose their pool assignment:", deleteUnassigns: "These projects lose their pool assignment:",
reloginTitle: "Sign in again {name}", deleteBlockedTooltip:
"Deletion blocked — active: {projects}. Stop the session first.",
reloginTitle: "Reset {name}",
reloginWarning: reloginWarning:
"The access token for {name} stored in the keychain will be deleted. The browser then opens for a new sign-in.", "The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.",
reloginNoEntry: reloginNoEntry:
"No keychain entry present — only the new sign-in happens.", "No keychain entry present — claude signs in via /login on the next start anyway.",
reloginBlocked: reloginBlocked:
"Signing in again requires an unused pool. These projects are still running:", "Resetting requires an unused pool. These projects are still running:",
}, },
}; };
+35 -1
View File
@@ -208,6 +208,35 @@ button.danger:hover:not(:disabled) {
width: 9.5rem; width: 9.5rem;
} }
/* Tooltip-Träger um den Löschen-Button: ein disabled Button empfängt keine
Hover-Events, darum sitzt das Hover-Pop am Span und der Button ist im
gesperrten Zustand für die Maus durchlässig. WKWebView zeigt keine nativen
title-Tooltips, daher das CSS-Pop (hover-pop/pop). */
.tip-wrap {
display: inline-flex;
}
.tip-wrap button:disabled {
pointer-events: none;
}
/* Pop rechtsbündig — der Löschen-Button steht am rechten Zeilenrand.
Schmaler als das Standard-Pop, damit der Hinweis auf mind. zwei Zeilen
umbricht statt in einer sehr breiten Zeile zu stehen. */
.hover-pop .pop.pop-right {
left: auto;
right: 0;
min-width: 0;
width: 13rem;
max-width: 13rem;
}
.pop-text {
font-size: 1rem;
line-height: 1.5;
color: var(--text);
white-space: normal;
overflow-wrap: anywhere;
}
button.gear { button.gear {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -584,6 +613,11 @@ td select {
cursor: pointer; cursor: pointer;
} }
.dialog .field .checkline {
font-size: 0.85rem;
color: var(--subtext);
}
.dialog .affected { .dialog .affected {
margin: 0; margin: 0;
padding-left: 1.1rem; padding-left: 1.1rem;
@@ -604,6 +638,6 @@ td select {
/* ---------- Formulare (Pools-Dialoge) ---------- */ /* ---------- Formulare (Pools-Dialoge) ---------- */
form.dialog input { form.dialog input:not([type="checkbox"]) {
width: 100%; width: 100%;
} }