Icons zentral unter ~/.config/ai-control/icons (pool-/repo-unabhängig), nicht ladbares Icon crasht den Tray nicht mehr; Arbeitsordner nachträglich im ⚙︎-Dialog erfassbar (additionalDirectories + Edit-Permission); Import-Button umbenannt; claudeCommand konfigurierbar + CLAUDE_CONFIG_DIR aus Pool; Pool-Tabellen-Layout; README englisch mit Screenshots; MIT-Lizenz

This commit is contained in:
marcus.hinz
2026-07-05 15:19:58 +02:00
parent b5995da755
commit 0908385a9d
14 changed files with 329 additions and 83 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name = "ai-control"
version = "0.1.0"
description = "Pool- und Session-Verwaltung für Claude Code"
authors = ["marcus.hinz"]
license = ""
license = "MIT"
repository = ""
edition = "2021"
rust-version = "1.77.2"
+177 -34
View File
@@ -48,6 +48,12 @@ impl Paths {
self.config_dir().join("pools")
}
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
/// unabhängig von Pool-Zuordnung und Quell-Repos.
fn icons_dir(&self) -> PathBuf {
self.config_dir().join("icons")
}
fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool)
}
@@ -208,6 +214,22 @@ fn sync_on_session_end(paths: &Paths) -> bool {
.unwrap_or(false)
}
/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
.unwrap_or_else(|| "claude".into())
}
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
let paths = Paths::real();
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
}
/// Setzt das Opt-in; erhält übrige App-settings.
fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
let path = paths.config_dir().join(APP_SETTINGS_FILE);
@@ -563,6 +585,76 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
register_project(paths, name, &dir)
}
/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
/// Datei an, wenn sie fehlt (importierte Projekte).
fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?;
let wd_path = expand_home(paths, dir);
if !wd_path.is_dir() {
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
}
let dir = contract_home(paths, &wd_path);
let sp = settings_path(&project_dir(paths, name)?);
let mut v: serde_json::Value = if sp.is_file() {
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
} else {
serde_json::json!({})
};
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let perms = root
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms
.entry("additionalDirectories")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
return Err(format!("schon eingetragen: {dir}"));
}
dirs.push(serde_json::json!(dir));
let allow = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.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()))
}
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
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 perms = v["permissions"]
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms["additionalDirectories"]
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
let before = dirs.len();
dirs.retain(|d| d.as_str() != Some(dir));
if dirs.len() == before {
return Err(format!("nicht eingetragen: {dir}"));
}
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
}
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()))
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
check_name(name)?;
@@ -648,8 +740,9 @@ fn set_terminal_config_in(
project: &str,
mut terminal: TerminalConfig,
) -> Result<(), String> {
// Absolut gewählte Icons in den Projektordner kopieren und relativ
// speichern — so wird das Icon mit dem Projekt-Repo gesynct.
let mut cfg = read_project_config_in(paths, project)?;
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
if let Some(icon) = terminal.icon.as_deref() {
if icon.starts_with('/') {
let src = PathBuf::from(icon);
@@ -658,25 +751,27 @@ fn set_terminal_config_in(
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
let dest = project_dir(paths, project)?.join(&name);
let name = format!("{project}.{ext}");
let icons = paths.icons_dir();
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
let dest = icons.join(&name);
if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
terminal.icon = Some(name);
}
}
let mut cfg = read_project_config_in(paths, project)?;
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App.
fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
if icon.starts_with('/') {
Ok(PathBuf::from(icon))
PathBuf::from(icon)
} else {
Ok(project_dir(paths, project)?.join(icon))
paths.icons_dir().join(icon)
}
}
@@ -689,7 +784,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None);
};
let path = resolve_icon_path(&paths, &project, &icon)?;
let path = resolve_icon_path(&paths, &icon);
let is_icns = path
.extension()
.and_then(|e| e.to_str())
@@ -742,34 +837,49 @@ fn menu_icon(
}
}
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
let src = resolve_icon_path(paths, project, &icon)?;
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
.arg(&src)
.arg("--out")
.arg(&tmp)
.output()
.map_err(|e| format!("sips: {e}"))?;
if !out.status.success() {
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?;
let (iw, ih) = (img.width() as usize, img.height() as usize);
let rgba = img.rgba();
for y in 0..ih.min(H) {
for x in 0..iw.min(H) {
let src_i = (y * iw + x) * 4;
let dst_i = (y * W + DOT_W + x) * 4;
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
// Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
// Meldung, Eintrag erscheint ohne Projekt-Icon.
match project_icon_rgba_36(paths, project, &icon) {
Ok(img) => {
let (iw, ih) = (img.width() as usize, img.height() as usize);
let rgba = img.rgba();
for y in 0..ih.min(H) {
for x in 0..iw.min(H) {
let src_i = (y * iw + x) * 4;
let dst_i = (y * W + DOT_W + x) * 4;
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
}
}
}
Err(e) => eprintln!("Tray-Icon {project}: {e}"),
}
}
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}
/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS).
fn project_icon_rgba_36(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<tauri::image::Image<'static>, String> {
let src = resolve_icon_path(paths, icon);
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
.arg(&src)
.arg("--out")
.arg(&tmp)
.output()
.map_err(|e| format!("sips: {e}"))?;
if !out.status.success() {
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
@@ -1434,6 +1544,16 @@ fn remove_project(name: String) -> Result<(), String> {
unregister_project(&Paths::real(), &name)
}
#[tauri::command]
fn add_work_dir(project: String, dir: String) -> Result<(), String> {
add_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
remove_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
@@ -1645,8 +1765,7 @@ pub fn run() {
.expect("Projekt-Config nicht lesbar")
.icon
.map(|i| {
resolve_icon_path(&Paths::real(), &project, &i)
.expect("Projekt nicht registriert")
resolve_icon_path(&Paths::real(), &i)
.to_string_lossy()
.into_owned()
});
@@ -1787,6 +1906,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
remove_project,
delete_project,
project_work_dirs,
add_work_dir,
remove_work_dir,
list_pools,
create_oauth_pool,
create_apikey_pool,
@@ -2610,6 +2731,28 @@ mod tests {
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty());
}
#[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
add_work_dir_in(&p, "proj", &picked).unwrap();
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]);
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(raw.contains("Edit(~/projects/extra/**)"));
assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty());
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(!raw.contains("~/projects/extra"));
// Ordner selbst bleibt
assert!(p.home.join("projects/extra").is_dir());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
+9 -5
View File
@@ -140,9 +140,9 @@ pub fn build_window(
Ok(())
}
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
/// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando
/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem
/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. zsh -i lädt die .zshrc (PATH, fnm).
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
@@ -151,16 +151,20 @@ pub fn term_start(
rows: u16,
cols: u16,
) -> Result<(), String> {
let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
let paths = crate::Paths::real();
let cwd = crate::project_dir(&paths, &project)?;
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(["-ic", "claude-sync"]);
cmd.args(["-ic", &crate::claude_command(&paths)]);
cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color");
if let Some(pool_dir) = crate::project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
}
let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
drop(pty.slave);