Modul-Split: Fachlogik nach domain/, OS-Code nach platform/, Tauri-Verdrahtung in app.rs/commands.rs

- lib.rs nur noch Prozessrollen-Dispatch + Panic-Tracer
- KWin-Script als KDE-Pendant zur GNOME-Extension (Popup ans Zeiger-
  Rechteck), wird von deb/rpm mit ausgeliefert
- SNI-Tray auf ksni (pure Rust), appindicator-Depends entfallen
This commit is contained in:
marcus hinz
2026-07-15 20:10:33 +02:00
parent cf844c45da
commit 9a99bca466
32 changed files with 4413 additions and 3572 deletions
@@ -0,0 +1,37 @@
// KWin-Script (Plasma 6): das Pendant zur GNOME-Shell-Extension.
// Unter Wayland darf nur der Compositor ein Fenster positionieren — die App
// (Client) kann das nicht. Dieses Script schiebt unser rahmenloses Popup an
// den Mauszeiger, der beim Tray-Klick auf dem Icon sitzt. Sonst nichts.
//
// Kein Drift: Fenster, UI und Verhalten kommen unverändert aus der App; hier
// wird ausschließlich platziert.
const WIN_TITLE = "ai-control-popup";
function place(w) {
if (!w || w.caption !== WIN_TITLE) {
return;
}
const cursor = workspace.cursorPos;
// MaximizeArea = Arbeitsfläche ohne Panels; so sitzt das Popup neben der
// Leiste, nicht darunter.
const area = workspace.clientArea(KWin.MaximizeArea, w);
const width = w.frameGeometry.width;
const height = w.frameGeometry.height;
// Rechte Fensterkante an den Zeiger; oben/unten aus dem Klemmen an die
// Arbeitsflächenkante (Panel oben -> unter dem Icon, Panel unten -> darüber).
let x = cursor.x - width;
let y = cursor.y;
if (x < area.x) x = area.x;
if (x + width > area.x + area.width) x = area.x + area.width - width;
if (y < area.y) y = area.y;
if (y + height > area.y + area.height) y = area.y + area.height - height;
w.frameGeometry = Qt.rect(x, y, width, height);
}
// windowAdded feuert beim (erneuten) Mappen des Popups, windowActivated fängt
// das Wiederanzeigen ab, falls der Compositor das Fenster nur reaktiviert.
workspace.windowAdded.connect(place);
workspace.windowActivated.connect(place);
@@ -0,0 +1,13 @@
{
"KPlugin": {
"Id": "ai-control-popup",
"Name": "ai-control Popup Positioner",
"Description": "Schiebt das rahmenlose ai-control-Popup an den Mauszeiger — das Pendant zur GNOME-Shell-Extension, nur für KWin (Wayland erlaubt Positionieren nur im Compositor).",
"Authors": [{ "Name": "marcus.hinz" }],
"Version": "1.0",
"License": "MIT",
"EnabledByDefault": false
},
"X-Plasma-API": "javascript",
"X-Plasma-MainScript": "code/main.js"
}
+112 -2
View File
@@ -35,6 +35,7 @@ dependencies = [
"base64 0.22.1",
"glib",
"keyring",
"ksni",
"log",
"objc2",
"objc2-app-kit",
@@ -94,6 +95,15 @@ dependencies = [
"libc",
]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anyhow"
version = "1.0.103"
@@ -266,6 +276,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "auto-launch"
version = "0.5.0"
@@ -623,6 +644,21 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -803,7 +839,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.118",
]
@@ -829,6 +865,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-codegen"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c"
dependencies = [
"clap",
"dbus",
"xml-rs",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
@@ -839,6 +886,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "dbus-tree"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508"
dependencies = [
"dbus",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1660,6 +1716,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -2090,6 +2155,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ksni"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c7d627125aac4ea80802d51d9f8c3e4ceb948792414b0e345903ff78337b3a7"
dependencies = [
"dbus",
"dbus-codegen",
"dbus-tree",
"thiserror 1.0.69",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2779,7 +2856,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"hermit-abi 0.5.2",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
@@ -3714,6 +3791,12 @@ dependencies = [
"quote",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4180,6 +4263,15 @@ dependencies = [
"utf-8",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4604,6 +4696,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "url"
version = "2.5.8"
@@ -4665,6 +4763,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5455,6 +5559,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xml-rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "yoke"
version = "0.8.3"
+1
View File
@@ -39,6 +39,7 @@ keyring = { version = "3", features = ["apple-native", "sync-secret-service", "w
zbus = "5"
glib = "0.18"
rfd = { version = "0.15", default-features = false, features = ["gtk3"] }
ksni = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
+301
View File
@@ -0,0 +1,301 @@
//! Tauri-Verdrahtung: Haupt-App (Tray, main-/popup-Fenster, Watcher) und
//! Terminal-Prozess. Das Popup ist auf allen OS dasselbe HTML-Fenster; nur
//! der Klick-Trigger kommt aus platform::init_tray (Anchor-Kontrakt).
use crate::domain::paths::Paths;
use crate::domain::pool::provision_pools_for_panel;
use crate::domain::project::terminal_config;
use crate::domain::watcher::spawn_session_watcher;
use crate::platform::Anchor;
use crate::{commands, terminal};
/// Sperrt das Auto-Hide des Popups bei Fokusverlust für einen Moment nach dem
/// Anzeigen (KDE-Erststart-Race). Als Tauri-State geführt.
#[cfg(target_os = "linux")]
pub(crate) struct PopupBlurGuard(pub(crate) std::sync::Arc<std::sync::atomic::AtomicBool>);
/// Popup an den Klick-Koordinaten platzieren, in den Monitor geklemmt.
#[cfg(target_os = "linux")]
fn position_popup(w: &tauri::WebviewWindow, x: i32, y: i32) {
use tauri::{PhysicalPosition, PhysicalSize};
let size = w.outer_size().unwrap_or_else(|_| PhysicalSize::new(320, 300));
let (mon_pos, mon_size) = w
.current_monitor()
.ok()
.flatten()
.map(|m| (*m.position(), *m.size()))
.unwrap_or((PhysicalPosition::new(0, 0), PhysicalSize::new(1920, 1080)));
// Rechte Kante an den Klick; oben/unten ergibt sich aus dem Klemmen an die
// Monitorkante (Panel oben -> unter dem Icon, Panel unten -> darüber).
let max_x = mon_pos.x + mon_size.width as i32 - size.width as i32;
let max_y = mon_pos.y + mon_size.height as i32 - size.height as i32;
let px = (x - size.width as i32).clamp(mon_pos.x, max_x.max(mon_pos.x));
let py = y.clamp(mon_pos.y, max_y.max(mon_pos.y));
let _ = w.set_position(PhysicalPosition::new(px, py));
}
/// Popup zeigen — ein Codepfad für alle OS, nur die Platzierung folgt dem
/// Anchor aus dem Tray-Trigger.
pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) {
use tauri::Manager;
let win = app.clone();
let _ = app.run_on_main_thread(move || {
let Some(w) = win.get_webview_window("popup") else {
return;
};
match anchor {
// Nativer Tray (macOS/Windows): rechte Popup-Kante an der rechten
// Icon-Kante; macOS unter dem Menüleisten-Icon, Windows über der Taskbar.
Anchor::IconRect { rect, popup_below } => {
let scale = w.scale_factor().unwrap_or(1.0);
let pos = rect.position.to_physical::<f64>(scale);
let size = rect.size.to_physical::<f64>(scale);
let win_w = w.outer_size().map(|s| s.width as f64).unwrap_or(320.0);
let x = (pos.x + size.width - win_w).max(0.0);
let y = if popup_below {
pos.y + size.height
} else {
let win_h = w.outer_size().map(|s| s.height as f64).unwrap_or(300.0);
(pos.y - win_h).max(0.0)
};
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
}
// SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren
// Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon.
Anchor::Cursor => {
#[cfg(target_os = "linux")]
{
if let Some(g) = win.try_state::<PopupBlurGuard>() {
g.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
match win.cursor_position() {
Ok(p) => position_popup(&w, p.x as i32, p.y as i32),
Err(_) => {
let _ = w.center();
}
}
// Guard nach kurzer Zeit lösen — danach schließt Fokusverlust wieder
// normal.
let g2 = win.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(350));
if let Some(g) = g2.try_state::<PopupBlurGuard>() {
g.0.store(false, std::sync::atomic::Ordering::SeqCst);
}
});
}
}
// GNOME-Extension/KWin: der Compositor positioniert selbst.
Anchor::Managed => {}
}
let _ = w.show();
let _ = w.set_focus();
});
}
/// Popup verstecken (GNOME-Toggle über D-Bus Hide()).
pub(crate) fn hide_popup(app: &tauri::AppHandle) {
use tauri::Manager;
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.hide();
}
});
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher();
// Panel-MCP-Server + Tool-Freigabe in alle Pools legen (auch bestehende),
// damit write_panel in jedem CLAUDE_CONFIG_DIR ohne Rückfrage bereitsteht.
provision_pools_for_panel(&Paths::real());
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet (No-op außerhalb Linux).
crate::platform::sync_all_desktops(&Paths::real());
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control")
.inner_size(800.0, 600.0)
// Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab,
// unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon.
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/128x128.png"
))?)?
.visible(false);
// Wie die Terminal-Fenster: eigener Header als Titelleiste. macOS behält die
// Ampel (Overlay), Linux ist dekorationslos (eigene Fensterknöpfe im Header).
#[cfg(target_os = "macos")]
let main_win = main_win
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
#[cfg(target_os = "linux")]
let main_win = main_win.decorations(false);
main_win.build()?;
// Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Nur
// der Weg zum Klick unterscheidet sich (platform::init_tray).
tauri::WebviewWindowBuilder::new(app, "popup", tauri::WebviewUrl::App("popup.html".into()))
.title("ai-control-popup")
.inner_size(320.0, 300.0)
.decorations(false)
.visible(false)
.transparent(true)
.skip_taskbar(true)
.always_on_top(true)
.resizable(false)
.build()?;
#[cfg(target_os = "linux")]
{
use tauri::Manager;
app.manage(PopupBlurGuard(std::sync::Arc::new(
std::sync::atomic::AtomicBool::new(false),
)));
}
// Der gesamte Tray-Kontrakt: Icon zeigen, Klick als Anchor melden —
// was dann passiert, entscheidet ausschließlich dieser Code.
crate::platform::init_tray(
app.handle(),
crate::platform::TrayCallbacks {
show: Box::new(|app, anchor| show_popup(app, anchor)),
hide: Box::new(hide_popup),
},
)?;
Ok(())
})
// Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü.
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
window.hide().unwrap();
}
// Popup schließt bei Fokusverlust.
if let tauri::WindowEvent::Focused(false) = event {
if window.label() == "popup" {
// KDE feuert direkt nach show/set_focus ein Focused(false) — ohne
// diese kurze Sperre verschwände das Popup sofort wieder (erst der
// zweite Klick hielte). GNOME/macOS setzen die Sperre nie.
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(g) = window.try_state::<PopupBlurGuard>() {
if g.0.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
}
}
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::list_projects,
commands::create_project_full,
commands::add_project,
commands::remove_project,
commands::delete_project,
commands::project_work_dirs,
commands::set_project_dir,
commands::add_work_dir,
commands::remove_work_dir,
commands::list_pools,
commands::create_oauth_pool,
commands::create_apikey_pool,
commands::rename_pool,
commands::delete_pool,
commands::assign_pool,
commands::unassign_pool,
commands::set_terminal_config,
commands::project_icon,
commands::pool_label,
commands::todo_state,
commands::set_todo,
commands::usage_stats,
commands::stop_project,
commands::restart_project,
commands::start_or_focus_cmd,
commands::open_main_window,
commands::quit_app,
commands::sync_setting,
commands::set_sync_setting,
commands::terminal_font_size,
commands::set_terminal_font_size,
commands::link_pool_runtime,
commands::oauth_login,
commands::keychain_status,
commands::set_apikey,
terminal::open_terminal
])
}
/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(terminal::Terminals::default())
.setup(move |app| {
let cfg = terminal_config(&project)?;
terminal::build_window(app.handle(), &project, &cfg)?;
Ok(())
})
// Fenster zu → PTY-Session abräumen; danach endet der Prozess. Das
// abgelöste Panel-Fenster hat keine PTY: sein Schließen dockt das Panel
// wieder an (panel-attached), der Prozess läuft weiter.
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
if window.label().starts_with("panel-") {
use tauri::{Emitter, Manager};
let _ = window.app_handle().emit("panel-window-closed", ());
} else {
terminal::close(window);
}
}
})
.invoke_handler(tauri::generate_handler![
terminal::term_start,
terminal::term_log,
terminal::term_write,
terminal::term_resize,
terminal::panel_read,
terminal::panel_set,
terminal::open_panel_window,
commands::panel_archive_dir_cmd,
commands::panel_archive_cmd,
commands::reveal_path_cmd,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
commands::list_projects,
commands::project_icon,
commands::pool_label,
commands::terminal_font_size,
commands::set_terminal_font_size,
commands::spellcheck_lang
])
}
+354
View File
@@ -0,0 +1,354 @@
//! Tauri-Commands der Verwaltungs-UI (Haupt-App, Popup, Terminal-Header) —
//! dünne Delegation an domain/ und platform/.
use std::time::{Duration, Instant};
use crate::domain::archive::{archive_panel_content, project_archive_dir};
use crate::domain::credentials::keychain_service;
use crate::domain::paths::Paths;
use crate::domain::pool::{
create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool,
link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo,
};
use crate::domain::project::{
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in,
is_running, kill_terminals, list_projects_in, project_work_dirs_in, read_project_config_in,
remove_work_dir_in, resolve_icon_path, running_projects_using_pool, set_project_dir_in,
set_terminal_config_in, unassign_pool_in, Project, TerminalConfig,
};
use crate::domain::registry::unregister_project;
use crate::domain::settings;
use crate::domain::todo::{set_todo_in, todo_state_in};
use crate::domain::usage::{usage_stats_in, UsageRow};
use crate::platform::KeychainStore;
use crate::terminal;
// ---------- Projekte ----------
#[tauri::command]
pub(crate) fn list_projects() -> Result<Vec<Project>, String> {
list_projects_in(&Paths::real())
}
#[tauri::command]
pub(crate) fn create_project_full(
name: String,
dir: Option<String>,
pool: Option<String>,
work_dir: Option<String>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> {
create_project_full_in(
&Paths::real(),
&name,
dir.as_deref(),
pool.as_deref(),
work_dir.as_deref(),
create_work_dir,
terminal,
todo,
)
}
/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
#[tauri::command]
pub(crate) fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path)
}
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
pub(crate) fn remove_project(name: String) -> Result<(), String> {
if is_running(&name) {
return Err(format!("{name} läuft noch — erst beenden"));
}
unregister_project(&Paths::real(), &name)
}
/// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command]
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
if is_running(&project) {
return Err(format!("{project} läuft noch — erst beenden"));
}
set_project_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
pub(crate) fn add_work_dir(project: String, dir: String) -> Result<(), String> {
add_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
pub(crate) fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
remove_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
pub(crate) fn delete_project(name: String, delete_work_dirs: bool) -> Result<(), String> {
if is_running(&name) {
return Err(format!("{name} läuft noch — erst beenden"));
}
delete_project_in(&Paths::real(), &name, delete_work_dirs)
}
#[tauri::command]
pub(crate) fn project_work_dirs(project: String) -> Result<Vec<String>, String> {
project_work_dirs_in(&Paths::real(), &project)
}
#[tauri::command]
pub(crate) fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
}
#[tauri::command]
pub(crate) fn set_todo(project: String, enabled: bool) -> Result<(), String> {
set_todo_in(&Paths::real(), &project, enabled)
}
#[tauri::command]
pub(crate) fn set_terminal_config(
project: String,
theme: Option<String>,
icon: Option<String>,
title: Option<String>,
) -> Result<(), String> {
set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
}
/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG).
#[tauri::command]
pub(crate) fn project_icon(project: String) -> Result<Option<String>, String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let paths = Paths::real();
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None);
};
let path = resolve_icon_path(&paths, &icon);
let mime = match path.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml",
_ => "image/png",
};
let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes))))
}
// ---------- Pools ----------
#[tauri::command]
pub(crate) fn list_pools() -> Result<Vec<PoolInfo>, String> {
list_pools_in(&Paths::real(), &KeychainStore)
}
#[tauri::command]
pub(crate) fn create_oauth_pool(name: String) -> Result<String, String> {
create_oauth_pool_in(&Paths::real(), &name)
}
#[tauri::command]
pub(crate) fn create_apikey_pool(
name: String,
key: String,
allow_file: bool,
) -> Result<String, String> {
create_apikey_pool_in(&Paths::real(), &KeychainStore, &name, &key, allow_file)
}
#[tauri::command]
pub(crate) fn rename_pool(pool: String, name: String) -> Result<(), String> {
rename_pool_in(&Paths::real(), &pool, &name)
}
#[tauri::command]
pub(crate) fn delete_pool(pool: String) -> Result<(), String> {
let paths = Paths::real();
let running = running_projects_using_pool(&paths, &pool)?;
if !running.is_empty() {
return Err(format!(
"Pool wird noch benutzt — läuft: {}",
running.join(", ")
));
}
delete_pool_in(&paths, &KeychainStore, &pool)
}
#[tauri::command]
pub(crate) fn assign_pool(project: String, pool: String) -> Result<(), String> {
assign_pool_in(&Paths::real(), &project, &pool)
}
#[tauri::command]
pub(crate) fn unassign_pool(project: String) -> Result<(), String> {
unassign_pool_in(&Paths::real(), &project)
}
/// Anzeigename eines Pools (pool.json) — die ID ist der Ordnername.
#[tauri::command]
pub(crate) fn pool_label(pool: String) -> Result<String, String> {
Ok(read_pool(&Paths::real(), &pool)?.name)
}
#[tauri::command]
pub(crate) fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<(), String> {
set_apikey_in(&Paths::real(), &KeychainStore, &pool, &key, allow_file)
}
#[tauri::command]
pub(crate) fn keychain_status(pool: String) -> Result<bool, String> {
crate::platform::oauth_keychain_exists(&keychain_service(&Paths::real(), &pool))
}
/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
/// damit claude beim nächsten Start des Pools erneut `/login` verlangt. Nur
/// bei ungenutztem Pool. Der Login selbst passiert in der Session, nicht hier.
#[tauri::command]
pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real();
ensure_oauth_pool(&paths, &pool)?;
let running = running_projects_using_pool(&paths, &pool)?;
if !running.is_empty() {
return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
running.join(", ")
));
}
let service = keychain_service(&paths, &pool);
if crate::platform::oauth_keychain_exists(&service)? {
crate::platform::oauth_keychain_delete(&service)?;
}
Ok(())
}
/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool —
/// sonst würde das Transkript der laufenden Session ersetzt.
#[tauri::command]
pub(crate) fn link_pool_runtime(pool: String) -> Result<(), String> {
let paths = Paths::real();
if !running_projects_using_pool(&paths, &pool)?.is_empty() {
return Err(format!("{pool} wird benutzt — erst Session beenden"));
}
link_pool_runtime_in(&paths, &pool)
}
// ---------- Sessions ----------
#[tauri::command]
pub(crate) fn stop_project(project: String) -> Result<(), String> {
if crate::platform::terminal_pids(&project).is_empty() {
return Err(format!("{project} läuft nicht"));
}
kill_terminals(&project)
}
/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu.
#[tauri::command]
pub(crate) fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
kill_terminals(&project)?;
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));
}
terminal::open_terminal(app, project)
}
/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es.
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
match crate::platform::terminal_pids(project).first() {
Some(pid) => crate::platform::focus_terminal(*pid),
None => {
if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {
eprintln!("{project} starten: {e}");
}
}
}
}
/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren.
#[tauri::command]
pub(crate) fn start_or_focus_cmd(app: tauri::AppHandle, project: String) {
start_or_focus(&app, &project);
}
// ---------- Popup-Fußzeile ----------
/// Popup-Fußzeile „Öffnen": das Hauptfenster zeigen.
#[tauri::command]
pub(crate) fn open_main_window(app: tauri::AppHandle) {
use tauri::Manager;
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
/// Popup-Fußzeile „Beenden": App verlassen.
#[tauri::command]
pub(crate) fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}
// ---------- App-Settings ----------
#[tauri::command]
pub(crate) fn sync_setting() -> Result<bool, String> {
Ok(settings::sync_on_session_end(&Paths::real()))
}
#[tauri::command]
pub(crate) fn set_sync_setting(enabled: bool) -> Result<(), String> {
settings::set_sync_on_session_end_in(&Paths::real(), enabled)
}
#[tauri::command]
pub(crate) fn terminal_font_size() -> u32 {
settings::terminal_font_size(&Paths::real())
}
#[tauri::command]
pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> {
settings::set_terminal_font_size_in(&Paths::real(), size)
}
#[tauri::command]
pub(crate) fn spellcheck_lang() -> String {
settings::spellcheck_lang(&Paths::real())
}
// ---------- Verbrauch ----------
#[tauri::command]
pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
usage_stats_in(&Paths::real(), days)
}
// ---------- Panel-Archiv ----------
/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,
/// ob der Ordner-Dialog nötig ist).
#[tauri::command]
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
project_archive_dir(&project).map(|p| p.display().to_string())
}
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.
#[tauri::command]
pub(crate) fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())
}
/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen").
#[tauri::command]
pub(crate) fn reveal_path_cmd(path: String) {
crate::platform::reveal_path(std::path::Path::new(&path));
}
+209
View File
@@ -0,0 +1,209 @@
//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im
//! konfigurierten Archiv-Ordner des Projekts persistieren.
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::domain::paths::{contract_home, expand_home, panel_file, Paths};
use crate::domain::project::{
read_project_config_in, settings_path, write_project_config_in,
};
use crate::domain::registry::project_dir;
/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
let paths = Paths::real();
let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
Some(expand_home(&paths, &dir))
}
/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
let paths = Paths::real();
let expanded = expand_home(&paths, dir);
if !expanded.is_absolute() {
return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display()));
}
// Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde
// claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab.
if paths.home.starts_with(&expanded) {
return Err(format!(
"Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.",
expanded.display()
));
}
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
let contracted = contract_home(&paths, &expanded);
let mut cfg = read_project_config_in(&paths, project)?;
cfg.archive_dir = Some(contracted.clone());
write_project_config_in(&paths, project, &cfg)?;
add_archive_permission(&paths, project, &contracted)
}
/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
let sp = settings_path(&project_dir(paths, project)?);
let mut v: serde_json::Value = if sp.is_file() {
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
.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)) {
dirs.push(serde_json::json!(dir));
}
let edit = format!("Edit({dir}/**)");
let allow = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
allow.push(serde_json::json!(edit));
}
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()))
}
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
pub(crate) fn archive_panel_content(
project: &str,
dir_override: Option<&str>,
) -> Result<PathBuf, String> {
if let Some(d) = dir_override {
set_project_archive_dir(project, d)?;
}
let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
if text.trim().is_empty() {
return Err("Panel ist leer — nichts zu archivieren".into());
}
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text);
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
let doc = format!(
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
title.replace('"', "'"),
text.trim_end(),
);
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
fn first_line(text: &str) -> String {
let mut fallback: Option<&str> = None;
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let h = t.trim_start_matches('#').trim();
if t.starts_with('#') && !h.is_empty() {
return h.to_string();
}
fallback.get_or_insert(t);
}
fallback.unwrap_or("entwurf").to_string()
}
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let out: String = out.trim_matches('-').chars().take(60).collect();
if out.is_empty() {
"entwurf".into()
} else {
out
}
}
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
fn utc_stamp(secs: u64) -> (String, String) {
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
let (y, m, d) = civil_from_days((secs / 86400) as i64);
(
format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
)
}
/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_stamp_bekannte_zeit() {
// 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC.
let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15;
let (stamp, iso) = utc_stamp(secs);
assert_eq!(stamp, "2026-07-11_1114");
assert_eq!(iso, "2026-07-11T11:14:15Z");
}
#[test]
fn civil_from_days_referenz() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20_645), (2026, 7, 11));
}
#[test]
fn slugify_grenzen() {
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
assert_eq!(slugify(" "), "entwurf");
assert_eq!(slugify("###"), "entwurf");
}
#[test]
fn first_line_ueberschrift_oder_erste_zeile() {
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading");
assert_eq!(first_line(" \n"), "entwurf");
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Credential-Ablage der Pools. Der ApikeyStore abstrahiert den nativen
//! Secret-Store (macOS-Keychain / Secret Service / Credential Manager) —
//! die Implementierungen liegen in platform/.
use sha2::{Digest, Sha256};
use crate::domain::paths::Paths;
/// Service-Name der Einträge; Account ist die Pool-ID. Unter Linux legt die
/// keyring-Crate die Attribute service/username an — der apiKeyHelper liest
/// mit denselben Attributen über secret-tool.
pub(crate) const APIKEY_SERVICE: &str = "ai-control-apikey";
/// Fehler-Sentinel an die UI: Store nicht verfügbar und Datei-Ablage (noch)
/// nicht erlaubt — die UI fragt dann nach und wiederholt mit allow_file.
pub(crate) const KEYCHAIN_UNAVAILABLE: &str = "keychain-unavailable";
/// Ablage der API-Keys im nativen Secret-Store. Die Key-Datei im Pool-Ordner
/// bleibt Fallback, wenn der Store beim Schreiben nicht verfügbar ist.
pub(crate) trait ApikeyStore {
fn set(&self, pool: &str, key: &str) -> Result<(), String>;
fn has(&self, pool: &str) -> Result<bool, String>;
fn delete(&self, pool: &str) -> Result<(), String>;
}
/// Keychain-Service-Name des OAuth-Pools: claude legt pro CLAUDE_CONFIG_DIR
/// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256
/// über den Pool-Pfad.
pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> String {
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("Claude Code-credentials-{}", &hex[..8])
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// Referenzwert vom echten System: privateDefault → 096c4ef9
/// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag).
#[test]
fn keychain_service_suffix() {
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
assert_eq!(
keychain_service(&p, "privateDefault"),
"Claude Code-credentials-096c4ef9"
);
}
#[test]
fn apikey_helper_kette_referenz() {
let dir = PathBuf::from("/pools/abc");
let cmd = crate::platform::apikey_helper_command(&dir, "abc");
assert!(cmd.ends_with("|| cat '/pools/abc/apikey'"));
#[cfg(target_os = "macos")]
assert!(cmd.starts_with(
"security find-generic-password -w -s ai-control-apikey -a abc 2>/dev/null"
));
#[cfg(target_os = "linux")]
assert!(cmd.starts_with(
"secret-tool lookup service ai-control-apikey username abc 2>/dev/null"
));
}
}
+25
View File
@@ -0,0 +1,25 @@
//! OS-freier Kern, nach Domänen geschnitten. Alles hier ist über `Paths`
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
//! laufen ausschließlich über crate::platform.
pub(crate) mod archive;
pub(crate) mod credentials;
pub(crate) mod paths;
pub(crate) mod pool;
pub(crate) mod project;
pub(crate) mod registry;
pub(crate) mod settings;
pub(crate) mod todo;
pub(crate) mod usage;
pub(crate) mod watcher;
#[cfg(test)]
pub(crate) mod testutil;
/// Namensprüfung für Projekte und Pool-Anzeigenamen (keine Pfad-Bestandteile).
pub(crate) fn check_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
return Err(format!("ungültiger Name: {name}"));
}
Ok(())
}
+77
View File
@@ -0,0 +1,77 @@
//! Wurzelpfade der App und Home-Kontraktion/-Expansion.
use std::path::PathBuf;
/// Dateiname der Projekt-Registry unter ~/.config/ai-control.
const PROJECTS_FILE: &str = "projects.json";
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
pub(crate) struct Paths {
pub(crate) home: PathBuf,
}
impl Paths {
pub(crate) fn real() -> Self {
Paths { home: crate::platform::home_dir() }
}
/// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
/// Projekte ohne gewählten Zielordner.
pub(crate) fn projects_dir(&self) -> PathBuf {
self.home.join("claude-projects")
}
pub(crate) fn config_dir(&self) -> PathBuf {
self.home.join(".config").join("ai-control")
}
pub(crate) fn projects_file(&self) -> PathBuf {
self.config_dir().join(PROJECTS_FILE)
}
pub(crate) fn pools_dir(&self) -> PathBuf {
self.config_dir().join("pools")
}
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
/// unabhängig von Pool-Zuordnung und Quell-Repos.
pub(crate) fn icons_dir(&self) -> PathBuf {
self.config_dir().join("icons")
}
pub(crate) fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool)
}
/// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
fn panels_dir(&self) -> PathBuf {
self.config_dir().join("panels")
}
}
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
Paths::real().panels_dir().join(format!("{project}.md"))
}
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
if p == "~" {
return paths.home.clone();
}
match p.strip_prefix("~/") {
Some(rest) => paths.home.join(rest),
None => PathBuf::from(p),
}
}
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
pub(crate) fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
match p.strip_prefix(&paths.home) {
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => p.display().to_string(),
}
}
+824
View File
@@ -0,0 +1,824 @@
//! Pools: benannte Credential-Sets unter ~/.config/ai-control/pools/<id>.
//! Jeder Pool-Ordner ist ein vollständiges CLAUDE_CONFIG_DIR (settings.json,
//! CLAUDE.md, Panel-Skill, MCP-Registrierung); oauth-Credentials verwaltet
//! claude selbst (Keychain), die App speichert keine Tokens.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use crate::domain::check_name;
use crate::domain::credentials::{ApikeyStore, KEYCHAIN_UNAVAILABLE};
use crate::domain::paths::Paths;
use crate::domain::project::{is_running, projects_using_pool, unassign_pool_in};
use crate::domain::settings::pool_sync_dir;
/// Feste Dateinamen im Pool-Ordner.
pub(crate) const APIKEY_FILE: &str = "apikey";
pub(crate) const POOL_FILE: &str = "pool.json";
#[derive(Serialize, Deserialize)]
pub(crate) struct Pool {
pub(crate) name: String,
#[serde(rename = "credentialType")]
pub(crate) credential_type: String,
}
#[derive(Serialize)]
pub(crate) struct PoolInfo {
/// Ordnername unter pools/ (bei Neuanlagen eine UUID) — stabile ID, an der
/// Keychain-Suffix, Symlinks und Projekt-Zuordnungen hängen.
pub(crate) id: String,
/// Anzeigename aus pool.json, frei umbenennbar.
pub(crate) name: String,
#[serde(rename = "credentialType")]
pub(crate) credential_type: String,
pub(crate) projects: Vec<String>,
/// Teilmenge von `projects`, die gerade läuft (dasselbe `is_running` wie die
/// Projektliste). Der Löschen-Dialog sperrt darauf.
pub(crate) running: Vec<String>,
#[serde(rename = "hasCredentials")]
pub(crate) has_credentials: bool,
}
pub(crate) 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()))
}
pub(crate) fn list_pools_in(
paths: &Paths,
store: &dyn ApikeyStore,
) -> Result<Vec<PoolInfo>, String> {
let mut pools = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(pools);
}
let entries =
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
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| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let id = entry.file_name().to_string_lossy().into_owned();
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
let has_credentials = match pool.credential_type.as_str() {
"apikey" => {
store.has(&id)?
|| fs::read_to_string(entry.path().join(APIKEY_FILE))
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
_ => true,
};
let projects = projects_using_pool(paths, &id)?;
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
pools.push(PoolInfo {
id,
projects,
running,
name: pool.name,
credential_type: pool.credential_type,
has_credentials,
});
}
pools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(pools)
}
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
/// nicht — dann ist die Liste leer.
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(out);
}
for entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
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| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
}
Ok(out)
}
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
/// Ordner für einen neuen Pool: pools/<UUID v4>.
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
check_name(name)?;
if pool_names(paths)?.iter().any(|(_, n)| n == name) {
return Err(format!("Pool existiert bereits: {name}"));
}
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
}
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
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| format!("{}: {e}", dir.join(POOL_FILE).display()))
}
/// 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.
pub(crate) fn init_pool_config(
dir: &PathBuf,
extra: serde_json::Value,
) -> Result<(), String> {
let mut settings = serde_json::json!({
"promptSuggestionEnabled": false,
"awaySummaryEnabled": false,
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
});
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| format!("{}: {e}", dir.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| format!("{}: {e}", dir.join("settings.json").display()))?;
let claude_md = dir.join("CLAUDE.md");
if !claude_md.is_file() {
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
}
install_panel_skill(dir);
register_mcp_server(dir);
Ok(())
}
/// Skill, der das MCP-Tool `write_panel` für Entwürfe anweist. Liegt im
/// Pool-Ordner (= CLAUDE_CONFIG_DIR), damit claude ihn als User-Skill findet.
const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
/// MCP-Server-Key — zugleich das Label, das claude im Tool-Call anzeigt
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
const PANEL_MCP_SERVER: &str = "text-panel";
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
/// läuft. Namensschema: `mcp__<server>__<tool>`.
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
/// evtl. ältere, tee-basierte Fassung).
fn install_panel_skill(pool_dir: &std::path::Path) {
let skill_dir = pool_dir.join("skills").join("panel");
if fs::create_dir_all(&skill_dir).is_ok() {
let _ = fs::write(skill_dir.join("SKILL.md"), PANEL_SKILL);
}
}
/// Frühere Panel-Freigaben (Bash- und alter MCP-Key), die aus den Pool-Settings
/// entfernt werden.
const STALE_PANEL_PERMISSIONS: [&str; 3] =
["Bash(tee:*)", "Bash(cat:*)", "mcp__aicontrol__write_panel"];
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
fn ensure_panel_permission(pool_dir: &std::path::Path) {
let sp = pool_dir.join("settings.json");
let Ok(raw) = fs::read_to_string(&sp) else {
return;
};
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return;
};
let Some(obj) = v.as_object_mut() else { return };
let Some(perms) = obj
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
let Some(allow) = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
else {
return;
};
let before = allow.len();
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
if !has {
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
}
if has && allow.len() == before {
return; // nichts geändert
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&sp, out + "\n");
}
}
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
/// `.claude.json` des Pools (= CLAUDE_CONFIG_DIR, User-Scope für alle
/// Projekte des Pools). Vorhandene .claude.json wird gemergt, nicht ersetzt.
fn register_mcp_server(pool_dir: &std::path::Path) {
let Ok(exe) = std::env::current_exe() else {
return;
};
// Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt
// den Entwurf in den Chat statt ins Panel.
let desired = serde_json::json!({
"type": "stdio",
"command": exe.to_string_lossy(),
"args": ["--mcp-panel"],
"alwaysLoad": true,
});
let cfg = pool_dir.join(".claude.json");
let mut v = fs::read_to_string(&cfg)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let Some(obj) = v.as_object_mut() else { return };
let Some(servers) = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
// Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist
// claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht.
if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) {
return;
}
servers.remove("aicontrol"); // alter Key vor Umbenennung
servers.insert(PANEL_MCP_SERVER.into(), desired);
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&cfg, out + "\n");
}
}
/// Panel-Skill, MCP-Server-Registrierung und Tool-Freigabe in einen Pool
/// bringen. install_panel_skill überschreibt eine evtl. alte tee-Fassung.
fn provision_pool(pool_dir: &std::path::Path) {
install_panel_skill(pool_dir);
register_mcp_server(pool_dir);
ensure_panel_permission(pool_dir);
}
/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start).
pub(crate) fn provision_pools_for_panel(paths: &Paths) {
if let Ok(pools) = pool_names(paths) {
for (id, _) in pools {
provision_pool(&paths.pool_dir(&id));
}
}
}
/// Runtime, die pro Pool ins synced Repo gelinkt wird: Transkripte, Todos,
/// Prompt-Historie. (name, ist_ordner)
pub(crate) const SYNCED_RUNTIME: [(&str, bool); 3] =
[("projects", true), ("todos", true), ("history.jsonl", false)];
/// Zielort der synced Runtime-Daten eines Pools unterhalb von poolSyncDir.
pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
pool_sync_dir(paths)
.map(|d| d.join(pool))
.ok_or_else(|| "poolSyncDir ist nicht konfiguriert".to_string())
}
/// Ersetzt im Pool-Ordner projects/todos/history.jsonl durch Symlinks auf den
/// konfigurierten Sync-Ordner. Die Symlinks sind maschinenlokal, die Daten
/// reisen über den Sync des Zielordners (z. B. git).
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
check_name(pool)?;
let src = paths.pool_dir(pool);
let data = pool_data_dir(paths, pool)?;
for (name, is_dir) in SYNCED_RUNTIME {
let target = data.join(name);
if is_dir {
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
} else {
fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?;
if !target.exists() {
fs::write(&target, "").map_err(|e| format!("{}: {e}", target.display()))?;
}
}
let link = src.join(name);
if link.is_dir() && !link.is_symlink() {
fs::remove_dir_all(&link).map_err(|e| format!("{}: {e}", link.display()))?;
} else if link.is_symlink() || link.exists() {
fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?;
}
crate::platform::symlink(&target, &link)?;
}
Ok(())
}
/// Legt einen apikey-Pool an: Key in den Keychain/Keyring (Datei 0600 nur mit
/// allow_file), settings.json mit apiKeyHelper-Kette, CLAUDE.md, pool.json.
/// Ohne Store und ohne allow_file bricht die Anlage ab, bevor etwas entsteht.
/// Liefert die Pool-ID.
pub(crate) fn create_apikey_pool_in(
paths: &Paths,
store: &dyn ApikeyStore,
name: &str,
key: &str,
allow_file: bool,
) -> Result<String, String> {
let dir = check_new_pool(paths, name)?;
let key = key.trim();
if key.is_empty() {
return Err("leerer API-Key".into());
}
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if store.set(&id, key).is_err() {
if !allow_file {
return Err(KEYCHAIN_UNAVAILABLE.into());
}
crate::platform::write_secret_file(&dir.join(APIKEY_FILE), &format!("{key}\n"))?;
}
init_pool_config(
&dir,
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
)?;
write_pool_json(&dir, name, "apikey")?;
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
}
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
/// + pool.json. Die Anmeldung macht claude selbst beim ersten Start des Pools
/// (`/login`) und legt den Keychain-Eintrag an — die App speichert keine Tokens.
/// Liefert die Pool-ID.
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
let dir = check_new_pool(paths, name)?;
init_pool_config(&dir, serde_json::json!({}))?;
write_pool_json(&dir, name, "oauth")?;
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
}
/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
check_name(name)?;
let current = read_pool(paths, pool)?;
if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
return Err(format!("Pool existiert bereits: {name}"));
}
write_pool_json(&paths.pool_dir(pool), name, &current.credential_type)
}
/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
/// Sessions setzt der delete_pool-Command davor.
pub(crate) fn delete_pool_in(
paths: &Paths,
store: &dyn ApikeyStore,
name: &str,
) -> Result<(), String> {
let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() {
return Err(format!("Pool nicht gefunden: {name}"));
}
if read_pool(paths, name)?.credential_type == "apikey" {
store.delete(name)?;
}
for project in projects_using_pool(paths, name)? {
unassign_pool_in(paths, &project)?;
}
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))
}
/// Schreibt den API-Key eines apikey-Pools neu: in den Keychain/Keyring, die
/// Fallback-Datei wird dabei entfernt (migriert Datei-Pools beim Key-Ändern).
/// Ohne verfügbaren Store: mit allow_file in die Datei (0600), sonst Abbruch
/// ohne Änderung. Der apiKeyHelper wird auf die aktuelle Kette gehoben.
pub(crate) fn set_apikey_in(
paths: &Paths,
store: &dyn ApikeyStore,
pool: &str,
key: &str,
allow_file: bool,
) -> 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());
}
let dir = paths.pool_dir(pool);
let key_path = dir.join(APIKEY_FILE);
if store.set(pool, key).is_ok() {
if key_path.is_file() {
fs::remove_file(&key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
}
} else {
if !allow_file {
return Err(KEYCHAIN_UNAVAILABLE.into());
}
crate::platform::write_secret_file(&key_path, &format!("{key}\n"))?;
}
let settings_path = dir.join("settings.json");
let raw = fs::read_to_string(&settings_path)
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
let mut settings: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
settings["apiKeyHelper"] =
serde_json::json!(crate::platform::apikey_helper_command(&dir, pool));
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
}
pub(crate) fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
let p = read_pool(paths, pool)?;
if p.credential_type != "oauth" {
return Err(format!("Pool {pool} ist kein oauth-Pool"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::project::{
assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig,
};
use crate::domain::settings::APP_SETTINGS_FILE;
use crate::domain::testutil::{
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
};
#[test]
fn apikey_pool_anlegen() {
let p = tmp_paths();
let store = map_store();
let id = make_apikey_pool(&p, &store, "kunde", "sk-test-123");
// Ordnername ist eine UUID, der Anzeigename steht nur in pool.json.
assert!(uuid::Uuid::parse_str(&id).is_ok());
let dir = p.pool_dir(&id);
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");
// Key liegt im Store, keine Datei im Pool-Ordner.
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-test-123");
assert!(!dir.join(APIKEY_FILE).exists());
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(),
crate::platform::apikey_helper_command(&dir, &id)
);
}
#[test]
fn apikey_pool_anlegen_ohne_store_faellt_auf_datei_zurueck() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-test-123");
let dir = p.pool_dir(&id);
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);
// Der Helper ist dieselbe Kette — der Store-Teil findet nichts,
// cat liefert die Datei.
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(),
crate::platform::apikey_helper_command(&dir, &id)
);
}
/// Ohne Store und ohne allow_file bricht die Anlage ab — es entsteht nichts.
#[test]
fn apikey_anlegen_abbruch_ohne_store() {
let p = tmp_paths();
let err = create_apikey_pool_in(&p, &FailStore, "kunde", "sk-1", false).unwrap_err();
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
assert!(pool_names(&p).unwrap().is_empty());
assert!(!p.pools_dir().exists());
}
/// Key-Ändern ohne Store und ohne allow_file lässt alles unangetastet.
#[test]
fn apikey_aendern_abbruch_ohne_store() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
let dir = p.pool_dir(&id);
let settings_before = fs::read_to_string(dir.join("settings.json")).unwrap();
let err = set_apikey_in(&p, &FailStore, &id, "sk-neu", false).unwrap_err();
assert_eq!(err, KEYCHAIN_UNAVAILABLE);
assert_eq!(fs::read_to_string(dir.join(APIKEY_FILE)).unwrap(), "sk-alt\n");
assert_eq!(fs::read_to_string(dir.join("settings.json")).unwrap(), settings_before);
}
#[test]
fn oauth_pool_anlegen() {
let p = tmp_paths();
let id = make_oauth_pool(&p, "privat");
let dir = p.pool_dir(&id);
let pool: Pool =
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
assert_eq!(pool.credential_type, "oauth");
// Kein Credentials-File — den Login macht claude selbst beim ersten Start.
assert!(!dir.join(".credentials.json").exists());
// Grundausstattung: settings.json mit aufgeräumten UI-Defaults + CLAUDE.md,
// damit claude nicht ins Onboarding fällt und keine Vorschlagstabelle zeigt.
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
.unwrap();
assert_eq!(settings["promptSuggestionEnabled"], serde_json::json!(false));
assert_eq!(settings["awaySummaryEnabled"], serde_json::json!(false));
assert!(dir.join("CLAUDE.md").is_file());
}
#[test]
fn pool_init_erhaelt_bestehende_claude_md() {
let p = tmp_paths();
let dir = p.pool_dir("privat");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("CLAUDE.md"), "meine Vorgaben\n").unwrap();
// Anlegen darf die vorhandene CLAUDE.md nicht überschreiben.
// (check_new_pool erlaubt keinen bestehenden Ordner → direkt init testen)
init_pool_config(&dir, serde_json::json!({})).unwrap();
assert_eq!(
fs::read_to_string(dir.join("CLAUDE.md")).unwrap(),
"meine Vorgaben\n"
);
}
#[test]
fn pool_doppelt_anlegen_scheitert() {
let p = tmp_paths();
let store = map_store();
make_apikey_pool(&p, &store, "kunde", "sk-1");
let err = create_apikey_pool_in(&p, &store, "kunde", "sk-2", true).unwrap_err();
assert!(err.contains("existiert bereits"));
}
// -- Pool umbenennen --
#[test]
fn pool_umbenennen() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &id).unwrap();
rename_pool_in(&p, &id, "kunde-neu").unwrap();
// Nur der Anzeigename ändert sich — Ordner, Typ und Zuordnung bleiben.
let pool = read_pool(&p, &id).unwrap();
assert_eq!(pool.name, "kunde-neu");
assert_eq!(pool.credential_type, "apikey");
assert!(p.pool_dir(&id).is_dir());
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool.as_deref(), Some(id.as_str()));
}
#[test]
fn pool_umbenennen_auf_vergebenen_namen_scheitert() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
make_oauth_pool(&p, "privat");
let err = rename_pool_in(&p, &id, "privat").unwrap_err();
assert!(err.contains("existiert bereits"));
// Umbenennen auf den eigenen Namen ist erlaubt (No-op).
rename_pool_in(&p, &id, "kunde").unwrap();
}
#[test]
fn pool_leerer_key_scheitert() {
let p = tmp_paths();
assert!(create_apikey_pool_in(&p, &map_store(), "kunde", " ", true).is_err());
}
#[test]
fn pool_name_mit_slash_scheitert() {
let p = tmp_paths();
assert!(create_apikey_pool_in(&p, &map_store(), "a/b", "sk-1", true).is_err());
}
// -- Pool ändern --
#[test]
fn apikey_aendern() {
let p = tmp_paths();
let store = map_store();
let id = make_apikey_pool(&p, &store, "kunde", "sk-alt");
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
assert!(!p.pool_dir(&id).join(APIKEY_FILE).exists());
}
/// Datei-Pool (Anlage ohne Store, z. B. Bestand vor der Keychain-Ablage):
/// Key-Ändern hebt ihn in den Store, Datei und alter cat-Helper verschwinden.
#[test]
fn apikey_aendern_migriert_datei_in_store() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
let dir = p.pool_dir(&id);
// Bestand simulieren: Helper wie vor der Keychain-Ablage.
let old = serde_json::json!({ "apiKeyHelper": format!("cat '{}'", dir.join(APIKEY_FILE).display()) });
fs::write(dir.join("settings.json"), old.to_string()).unwrap();
let store = map_store();
set_apikey_in(&p, &store, &id, "sk-neu", false).unwrap();
assert_eq!(store.0.lock().unwrap().get(&id).unwrap(), "sk-neu");
assert!(!dir.join(APIKEY_FILE).exists());
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(),
crate::platform::apikey_helper_command(&dir, &id)
);
}
#[test]
fn apikey_aendern_ohne_store_schreibt_datei() {
let p = tmp_paths();
let id = make_apikey_pool(&p, &FailStore, "kunde", "sk-alt");
set_apikey_in(&p, &FailStore, &id, "sk-neu", true).unwrap();
let key_path = p.pool_dir(&id).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();
let id = make_oauth_pool(&p, "privat");
assert!(set_apikey_in(&p, &map_store(), &id, "sk-1", true).is_err());
}
#[test]
fn ensure_oauth_pool_auf_apikey_scheitert() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
assert!(ensure_oauth_pool(&p, &kunde).is_err());
let privat = make_oauth_pool(&p, "privat");
assert!(ensure_oauth_pool(&p, &privat).is_ok());
}
// -- Pool löschen --
#[test]
fn pool_loeschen() {
let p = tmp_paths();
let store = map_store();
let id = make_apikey_pool(&p, &store, "kunde", "sk-1");
delete_pool_in(&p, &store, &id).unwrap();
assert!(!p.pool_dir(&id).exists());
// Der Keychain-Eintrag geht mit.
assert!(!store.has(&id).unwrap());
}
#[test]
fn pool_key_status() {
let p = tmp_paths();
// Datei-Pool (ohne Store angelegt): Datei entscheidet.
let kunde = make_apikey_pool(&p, &FailStore, "kunde", "sk-1");
make_oauth_pool(&p, "privat");
let pools = list_pools_in(&p, &FailStore).unwrap();
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
// oauth: Keychain wird im Listing bewusst nicht geprüft → immer true.
assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials);
fs::write(p.pool_dir(&kunde).join(APIKEY_FILE), "\n").unwrap();
let pools = list_pools_in(&p, &FailStore).unwrap();
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
fs::remove_file(p.pool_dir(&kunde).join(APIKEY_FILE)).unwrap();
let pools = list_pools_in(&p, &FailStore).unwrap();
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
}
/// Store-Pool: hasCredentials kommt aus dem Keychain-Eintrag, ohne Datei.
#[test]
fn pool_key_status_aus_store() {
let p = tmp_paths();
let store = map_store();
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
assert!(!p.pool_dir(&kunde).join(APIKEY_FILE).exists());
let pools = list_pools_in(&p, &store).unwrap();
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
store.delete(&kunde).unwrap();
let pools = list_pools_in(&p, &store).unwrap();
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
}
#[test]
fn pool_loeschen_loest_zuordnungen() {
let p = tmp_paths();
let store = map_store();
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
assert!(!p.pool_dir(&kunde).exists());
assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists());
}
#[test]
fn pool_anlegen_verlinkt_synced_runtime() {
let p = tmp_paths();
fs::create_dir_all(p.config_dir()).unwrap();
fs::write(
p.config_dir().join(APP_SETTINGS_FILE),
"{ \"poolSyncDir\": \"~/claude-projects/pool\" }\n",
)
.unwrap();
let id = make_oauth_pool(&p, "privat");
let pooldir = p.pool_dir(&id);
for (name, is_dir) in SYNCED_RUNTIME {
let link = pooldir.join(name);
assert!(link.is_symlink(), "{name} sollte Symlink sein");
assert_eq!(fs::read_link(&link).unwrap(), pool_data_dir(&p, &id).unwrap().join(name));
let target = pool_data_dir(&p, &id).unwrap().join(name);
assert_eq!(target.is_dir(), is_dir);
assert!(target.exists());
}
// Zieldaten liegen unter dem konfigurierten poolSyncDir/<id>/
assert!(p.home.join("claude-projects/pool").join(&id).is_dir());
}
/// Ohne poolSyncDir bleibt alles lokal: keine Symlinks, Verlinken scheitert laut.
#[test]
fn pool_anlegen_ohne_sync_dir_bleibt_lokal() {
let p = tmp_paths();
let id = make_oauth_pool(&p, "privat");
for (name, _) in SYNCED_RUNTIME {
assert!(!p.pool_dir(&id).join(name).is_symlink(), "{name} darf kein Symlink sein");
}
let err = link_pool_runtime_in(&p, &id).unwrap_err();
assert!(err.contains("poolSyncDir"));
}
#[test]
fn pool_loeschen_erhaelt_terminal_config() {
let p = tmp_paths();
let store = map_store();
let kunde = make_apikey_pool(&p, &store, "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), icon: None, title: None },
)
.unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool, None);
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
}
#[test]
fn pool_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
assert!(delete_pool_in(&p, &map_store(), "gibtsnicht").is_err());
}
}
+762
View File
@@ -0,0 +1,762 @@
//! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung,
//! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json
//! und die ai-control.json (Pool + Terminal-Config).
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use crate::domain::check_name;
use crate::domain::paths::{contract_home, expand_home, Paths};
use crate::domain::pool::POOL_FILE;
use crate::domain::registry::{load_registry, project_dir, register_project, save_registry};
/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen).
pub(crate) const PROJECT_FILE: &str = "ai-control.json";
#[derive(Serialize)]
pub(crate) struct Project {
pub(crate) name: String,
pub(crate) path: String,
pub(crate) pool: Option<String>,
pub(crate) running: bool,
pub(crate) terminal: TerminalConfig,
}
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct ProjectConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pool: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
pub(crate) terminal: TerminalConfig,
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
#[serde(
default,
rename = "archiveDir",
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_dir: Option<String>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct TerminalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl TerminalConfig {
pub(crate) fn is_empty(&self) -> bool {
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
}
}
pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf {
dir.join(".claude").join("settings.json")
}
pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
}
pub(crate) fn read_project_config_in(
paths: &Paths,
project: &str,
) -> Result<ProjectConfig, String> {
let cfg_path = project_config_path(paths, project)?;
if !cfg_path.is_file() {
return Ok(ProjectConfig::default());
}
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()))
}
pub(crate) fn write_project_config_in(
paths: &Paths,
project: &str,
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
let path = project_config_path(paths, project)?;
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
pub(crate) fn is_running(project: &str) -> bool {
!crate::platform::terminal_pids(project).is_empty()
}
/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte PID.
pub(crate) fn kill_terminals(project: &str) -> Result<(), String> {
for pid in crate::platform::terminal_pids(project) {
crate::platform::kill_terminal(pid)?;
}
Ok(())
}
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
/// Lauf-Erkennung wie die Projektliste (`is_running`).
pub(crate) 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())
}
/// Namen aller Projekte, die `pool` zugeordnet haben.
pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new();
for (name, dir) in load_registry(paths)? {
let cfg_path = dir.join(PROJECT_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool.as_deref() == Some(pool) {
users.push(name);
}
}
Ok(users)
}
pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new();
for (name, dir) in load_registry(paths)? {
let cfg = read_project_config_in(paths, &name)?;
projects.push(Project {
running: is_running(&name),
path: contract_home(paths, &dir),
pool: cfg.pool,
terminal: cfg.terminal,
name,
});
}
Ok(projects)
}
/// 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)))
}
/// 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)
}
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in(
paths: &Paths,
name: &str,
dir: Option<&str>,
pool: Option<&str>,
work_dir: Option<&str>,
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> {
check_name(name)?;
let dir = match dir {
Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name),
};
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
if dir.exists() {
return Err(format!("Ordner existiert bereits: {}", dir.display()));
}
if let Some(pool) = pool {
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}"));
}
}
// Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
// halbes Projekt.
if let Some(wd) = work_dir {
let wd_path = expand_home(paths, wd);
if create_work_dir {
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
} else if !wd_path.is_dir() {
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
}
}
fs::create_dir_all(dir.join(".claude"))
.map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?;
fs::create_dir_all(dir.join("memory"))
.map_err(|e| format!("{}: {e}", dir.join("memory").display()))?;
fs::write(dir.join(".gitignore"), ".ai-control-running\n")
.map_err(|e| format!("{}: {e}", dir.join(".gitignore").display()))?;
let contracted = contract_home(paths, &dir);
let mut allow = vec![format!("Edit({contracted}/**)")];
let mut additional: Vec<String> = Vec::new();
if let Some(wd) = work_dir {
allow.insert(0, format!("Edit({wd}/**)"));
additional.push(wd.to_string());
}
let settings = serde_json::json!({
"autoMemoryDirectory": format!("{contracted}/memory"),
"permissions": {
"allow": allow,
"additionalDirectories": additional,
},
});
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| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
}
if todo {
crate::domain::todo::set_todo_in(paths, name, true)?;
}
crate::platform::write_terminal_desktop(paths, name, &cfg.terminal);
Ok(())
}
/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
/// auf den alten Root in der settings.json (autoMemoryDirectory,
/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner
/// muss existieren.
pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?;
let new_dir = expand_home(paths, dir);
if !new_dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
}
let mut reg = load_registry(paths)?;
let old_dir = reg
.get(name)
.cloned()
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
if old_dir == new_dir {
return Ok(());
}
reg.insert(name.to_string(), new_dir.clone());
save_registry(paths, &reg)?;
let sp = settings_path(&new_dir);
if !sp.is_file() {
return Ok(()); // importierte Projekte ohne settings.json
}
let old_c = contract_home(paths, &old_dir);
let new_c = contract_home(paths, &new_dir);
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()))?;
if let Some(mem) = v["autoMemoryDirectory"].as_str() {
if let Some(rest) = mem.strip_prefix(&old_c) {
v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}"));
}
}
if let Some(allow) = v["permissions"]["allow"].as_array_mut() {
let old_edit = format!("Edit({old_c}/**)");
for e in allow.iter_mut() {
if e.as_str() == Some(&old_edit) {
*e = serde_json::json!(format!("Edit({new_c}/**)"));
}
}
}
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 bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let dir = expand_home(paths, path);
if !dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
}
let name = dir
.file_name()
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)?;
let cfg = read_project_config_in(paths, &name)
.map(|c| c.terminal)
.unwrap_or_default();
crate::platform::write_terminal_desktop(paths, &name, &cfg);
Ok(())
}
/// 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).
pub(crate) 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.
pub(crate) 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.
pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(Vec::new());
}
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["permissions"]["additionalDirectories"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
)
}
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
pub(crate) fn delete_project_in(
paths: &Paths,
name: &str,
delete_work_dirs: bool,
) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
if !dir.is_dir() {
return Err(format!("Projekt nicht gefunden: {name}"));
}
if delete_work_dirs {
for wd in project_work_dirs_in(paths, name)? {
let wd_path = expand_home(paths, &wd);
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
}
}
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
crate::domain::registry::unregister_project(paths, name)?;
crate::platform::remove_terminal_desktop(paths, name);
Ok(())
}
pub(crate) 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 mut cfg = read_project_config_in(paths, project)?;
cfg.pool = Some(pool.to_string());
write_project_config_in(paths, project, &cfg)
}
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None;
if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
let cfg_path = project_config_path(paths, project)?;
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
}
write_project_config_in(paths, project, &cfg)
}
pub(crate) fn set_terminal_config_in(
paths: &Paths,
project: &str,
mut terminal: TerminalConfig,
) -> Result<(), String> {
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);
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
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);
}
}
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)?;
crate::platform::write_terminal_desktop(paths, project, &cfg.terminal);
Ok(())
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App.
pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
if icon.starts_with('/') {
PathBuf::from(icon)
} else {
paths.icons_dir().join(icon)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
#[test]
fn projekt_wizard_scaffold() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project_full_in(
&p,
"neu",
None,
Some(&kunde),
Some("~/projects/neu"),
true,
TerminalConfig {
theme: Some("dracula".into()),
icon: None,
title: Some("Neu".into()),
},
true,
)
.unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join("memory").is_dir());
assert_eq!(
fs::read_to_string(dir.join(".gitignore")).unwrap(),
".ai-control-running\n"
);
assert!(p.home.join("projects").join("neu").is_dir());
let cfg = read_project_config_in(&p, "neu").unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
assert_eq!(cfg.terminal.title.as_deref(), Some("Neu"));
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory");
assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)");
assert_eq!(
settings["permissions"]["allow"][1],
"Edit(~/claude-projects/neu/**)"
);
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
assert!(dir.join(TODO_FILE).is_file());
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(TODO_FILE));
}
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json
assert!(!project_config_path(&p, "neu").unwrap().is_file());
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)");
assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([]));
}
#[test]
fn projekt_wizard_doppelt_scheitert() {
let p = tmp_paths();
create_project(&p, "neu").unwrap();
let err =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
#[test]
fn projekt_wizard_fehlendes_workdir_scheitert() {
let p = tmp_paths();
let err = create_project_full_in(
&p,
"neu",
None,
None,
Some("~/projects/gibtsnicht"),
false,
TerminalConfig::default(),
false,
)
.unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt"));
// kein halbes Projekt zurückgeblieben
assert!(!p.projects_dir().join("neu").exists());
}
// -- Projekt zuordnen / rausnehmen / wechseln --
#[test]
fn projekt_zuordnen() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
.unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
let pools =
crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap();
assert_eq!(pools[0].projects, vec!["proj"]);
}
#[test]
fn projekt_zuordnen_pool_fehlt_scheitert() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
}
#[test]
fn projekt_rausnehmen() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
unassign_pool_in(&p, "proj").unwrap();
assert!(!project_config_path(&p, "proj").unwrap().exists());
}
#[test]
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
let p = tmp_paths();
create_project(&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();
let privat = make_oauth_pool(&p, "privat");
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &privat).unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).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(&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(&p, "proj").unwrap();
assert!(create_project(&p, "proj").is_err());
}
#[test]
fn projekt_loeschen() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
}
#[test]
fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(p.home.join("projects").join("proj").is_dir());
}
#[test]
fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(!p.home.join("projects").join("proj").exists());
}
#[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err());
}
#[test]
fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer
create_project(&p, "alt").unwrap();
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 projektordner_verlegen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj");
fs::create_dir_all(p.home.join("elsewhere")).unwrap();
fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap();
set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap();
assert_eq!(project_dir(&p, "proj").unwrap(), new_dir);
let raw = fs::read_to_string(settings_path(&new_dir)).unwrap();
assert!(raw.contains("~/elsewhere/proj/memory"));
assert!(raw.contains("Edit(~/elsewhere/proj/**)"));
assert!(!raw.contains("claude-projects/proj"));
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control.
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use crate::domain::paths::{contract_home, expand_home, Paths};
/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>, String> {
let file = paths.projects_file();
if !file.is_file() {
return Ok(BTreeMap::new());
}
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let map: BTreeMap<String, String> =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
Ok(
map
.into_iter()
.map(|(name, p)| {
let dir = expand_home(paths, &p);
(name, dir)
})
.collect(),
)
}
pub(crate) fn save_registry(
paths: &Paths,
reg: &BTreeMap<String, PathBuf>,
) -> Result<(), String> {
let contracted: BTreeMap<&String, String> =
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let file = paths.projects_file();
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
}
/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
}
/// Nimmt ein Projekt in die Registry auf.
pub(crate) fn register_project(
paths: &Paths,
name: &str,
dir: &std::path::Path,
) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
reg.insert(name.to_string(), dir.to_path_buf());
save_registry(paths, &reg)
}
/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
pub(crate) fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
let mut reg = load_registry(paths)?;
if reg.remove(name).is_none() {
return Err(format!("Projekt nicht registriert: {name}"));
}
save_registry(paths, &reg)
}
+93
View File
@@ -0,0 +1,93 @@
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang.
use std::fs;
use std::path::PathBuf;
use crate::domain::paths::{expand_home, Paths};
pub(crate) const APP_SETTINGS_FILE: &str = "settings.json";
fn read_app_settings(paths: &Paths) -> Option<serde_json::Value> {
let raw = fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE)).ok()?;
serde_json::from_str(&raw).ok()
}
/// Opt-in: synct der Watcher bei Session-Ende? Default aus.
pub(crate) fn sync_on_session_end(paths: &Paths) -> bool {
read_app_settings(paths)
.and_then(|v| v["syncOnSessionEnd"].as_bool())
.unwrap_or(false)
}
/// Setzt das Opt-in; erhält übrige App-settings.
pub(crate) fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
write_app_setting(paths, "syncOnSessionEnd", serde_json::json!(enabled))
}
/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
read_app_settings(paths)
.and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
.unwrap_or_else(|| "claude".into())
}
/// Sync-Ziel für Pool-Laufzeitdaten (settings.json: poolSyncDir).
/// Ungesetzt = Feature aus, alle Daten bleiben lokal im Pool-Ordner.
pub(crate) fn pool_sync_dir(paths: &Paths) -> Option<PathBuf> {
read_app_settings(paths)
.and_then(|v| v["poolSyncDir"].as_str().map(|d| expand_home(paths, d)))
}
/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
/// Terminals. Default 13.
pub(crate) fn terminal_font_size(paths: &Paths) -> u32 {
read_app_settings(paths)
.and_then(|v| v["terminalFontSize"].as_u64())
.map(|n| n as u32)
.unwrap_or(13)
}
/// Setzt die Schriftgröße; erhält übrige App-settings.
pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(), String> {
write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
}
/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang),
/// Default „de". Pro Text im Panel überschreibbar.
pub(crate) fn spellcheck_lang(paths: &Paths) -> String {
read_app_settings(paths)
.and_then(|v| v["spellcheckLang"].as_str().map(str::to_string))
.unwrap_or_else(|| "de".to_string())
}
/// Einen Schlüssel setzen; übrige App-settings bleiben erhalten.
fn write_app_setting(
paths: &Paths,
key: &str,
value: serde_json::Value,
) -> Result<(), String> {
let path = paths.config_dir().join(APP_SETTINGS_FILE);
let mut v = read_app_settings(paths).unwrap_or_else(|| serde_json::json!({}));
v[key] = value;
fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::tmp_paths;
#[test]
fn sync_optin_default_aus_und_umschaltbar() {
let p = tmp_paths();
assert!(!sync_on_session_end(&p)); // default: kein Sync ohne Zustimmung
set_sync_on_session_end_in(&p, true).unwrap();
assert!(sync_on_session_end(&p));
set_sync_on_session_end_in(&p, false).unwrap();
assert!(!sync_on_session_end(&p));
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Gemeinsame Test-Helfer der Domänen-Module: Pseudo-Home, In-Memory-Store,
//! Kurzformen für Pool-/Projekt-Anlage.
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::domain::credentials::ApikeyStore;
use crate::domain::paths::Paths;
use crate::domain::pool::{create_apikey_pool_in, create_oauth_pool_in};
use crate::domain::registry::register_project;
static N: AtomicU32 = AtomicU32::new(0);
/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
/// echten System.
pub(crate) 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 }
}
/// In-Memory-ApikeyStore statt echtem Keychain.
pub(crate) struct MapStore(pub(crate) std::sync::Mutex<HashMap<String, String>>);
pub(crate) fn map_store() -> MapStore {
MapStore(std::sync::Mutex::new(HashMap::new()))
}
impl ApikeyStore for MapStore {
fn set(&self, pool: &str, key: &str) -> Result<(), String> {
self.0.lock().unwrap().insert(pool.into(), key.into());
Ok(())
}
fn has(&self, pool: &str) -> Result<bool, String> {
Ok(self.0.lock().unwrap().contains_key(pool))
}
fn delete(&self, pool: &str) -> Result<(), String> {
self.0.lock().unwrap().remove(pool);
Ok(())
}
}
/// Store ohne Keychain/Keyring — Schreiben scheitert, Lesen findet nichts.
pub(crate) struct FailStore;
impl ApikeyStore for FailStore {
fn set(&self, _pool: &str, _key: &str) -> Result<(), String> {
Err("kein Keychain".into())
}
fn has(&self, _pool: &str) -> Result<bool, String> {
Ok(false)
}
fn delete(&self, _pool: &str) -> Result<(), String> {
Ok(())
}
}
/// Liefert die Pool-ID (UUID-Ordnername).
pub(crate) fn make_oauth_pool(p: &Paths, name: &str) -> String {
create_oauth_pool_in(p, name).unwrap()
}
pub(crate) fn make_apikey_pool(
p: &Paths,
store: &dyn ApikeyStore,
name: &str,
key: &str,
) -> String {
create_apikey_pool_in(p, store, name, key, true).unwrap()
}
/// Minimal-Projekt: Ordner mit .claude/ + Registry-Eintrag.
pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> {
crate::domain::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| format!("{}: {e}", dir.join(".claude").display()))?;
register_project(paths, name, &dir)
}
pub(crate) fn mode(path: &PathBuf) -> u32 {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path).unwrap().permissions().mode() & 0o777
}
+137
View File
@@ -0,0 +1,137 @@
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
use std::fs;
use crate::domain::check_name;
use crate::domain::paths::Paths;
use crate::domain::project::settings_path;
use crate::domain::registry::project_dir;
pub(crate) 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(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
dir.join(TODO_FILE).display()
)
}
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)
}
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(&project_dir(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),
)
}
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
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(&dir) }
]
}),
);
let todo_path = dir.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()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::project::{create_project_full_in, TerminalConfig};
use crate::domain::testutil::{create_project, tmp_paths};
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, 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.projects_dir().join("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(&p, "alt").unwrap();
assert!(set_todo_in(&p, "alt", true).is_err());
assert!(!todo_state_in(&p, "alt").unwrap());
}
}
+282
View File
@@ -0,0 +1,282 @@
//! Verbrauch: aggregiert message.usage aus den Transcript-JSONLs aller Pools
//! zu Pool × Projekt, mit Kostenschätzung nach Modell-Raten.
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::domain::paths::Paths;
use crate::domain::pool::{read_pool, POOL_FILE};
use crate::domain::registry::load_registry;
#[derive(Serialize, Default)]
pub(crate) struct UsageTotals {
#[serde(rename = "inputTokens")]
input_tokens: u64,
#[serde(rename = "outputTokens")]
output_tokens: u64,
#[serde(rename = "cacheCreationTokens")]
cache_creation_tokens: u64,
#[serde(rename = "cacheReadTokens")]
cache_read_tokens: u64,
#[serde(rename = "costUsd")]
cost_usd: f64,
}
#[derive(Serialize)]
pub(crate) struct UsageRow {
pool: String,
project: String,
#[serde(flatten)]
totals: UsageTotals,
}
/// USD pro MTok (input, output, cache_write, cache_read).
/// Cache: write 1.25× / read 0.1× des Input-Preises.
fn model_rates(model: &str) -> (f64, f64, f64, f64) {
let (input, output) = if model.contains("fable") || model.contains("mythos") {
(10.0, 50.0)
} else if model.contains("opus") {
(5.0, 25.0)
} else if model.contains("haiku") {
(1.0, 5.0)
} else {
// sonnet und Unbekanntes
(3.0, 15.0)
};
(input, output, input * 1.25, input * 0.1)
}
/// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
/// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden.
fn parse_ts(ts: &str) -> Option<i64> {
if ts.len() < 19 {
return None;
}
let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().ok();
let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s)
}
/// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt
/// (alles außer [a-zA-Z0-9] wird '-').
fn encode_project_path(path: &str) -> String {
path
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen,
/// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst).
fn add_usage_line(
line: &str,
cutoff: i64,
seen: &mut HashSet<String>,
totals: &mut UsageTotals,
) {
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
// Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session)
Err(_) => return,
};
let usage = &v["message"]["usage"];
if !usage.is_object() {
return;
}
match v["timestamp"].as_str().and_then(parse_ts) {
Some(t) if t >= cutoff => {}
_ => return,
}
if let Some(id) = v["message"]["id"].as_str() {
let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or(""));
if !seen.insert(key) {
return;
}
}
let model = v["message"]["model"].as_str().unwrap_or("");
if model == "<synthetic>" {
return;
}
let input = usage["input_tokens"].as_u64().unwrap_or(0);
let output = usage["output_tokens"].as_u64().unwrap_or(0);
let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let (ri, ro, rw, rr) = model_rates(model);
totals.input_tokens += input;
totals.output_tokens += output;
totals.cache_creation_tokens += cache_write;
totals.cache_read_tokens += cache_read;
totals.cost_usd += input as f64 / 1e6 * ri
+ output as f64 / 1e6 * ro
+ cache_write as f64 / 1e6 * rw
+ cache_read as f64 / 1e6 * rr;
}
/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let cutoff = now - days as i64 * 86400;
// kodierter Projektpfad → Projektname
let mut names = HashMap::new();
for (name, dir) in load_registry(paths)? {
names.insert(encode_project_path(&dir.to_string_lossy()), name);
}
let mut seen = HashSet::new();
let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
if !paths.pools_dir().is_dir() {
return Ok(Vec::new());
}
for pool_entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let id = pool_entry.file_name().to_string_lossy().into_owned();
// Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
let pool = if pool_entry.path().join(POOL_FILE).is_file() {
read_pool(paths, &id)?.name
} else {
id
};
let projects_root = pool_entry.path().join("projects");
if !projects_root.is_dir() {
continue;
}
for proj_entry in
fs::read_dir(&projects_root).map_err(|e| format!("{}: {e}", projects_root.display()))?
{
let proj_entry = proj_entry.map_err(|e| format!("{}: {e}", projects_root.display()))?;
if !proj_entry.path().is_dir() {
continue;
}
let encoded = proj_entry.file_name().to_string_lossy().into_owned();
// Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
// kodierten Namen ausweisen statt verschlucken.
let project = names.get(&encoded).cloned().unwrap_or(encoded);
let totals = rows.entry((pool.clone(), project)).or_default();
for file in fs::read_dir(proj_entry.path())
.map_err(|e| format!("{}: {e}", proj_entry.path().display()))?
{
let file = file.map_err(|e| format!("{}: {e}", proj_entry.path().display()))?;
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
for line in BufReader::new(f).lines() {
let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
add_usage_line(&line, cutoff, &mut seen, totals);
}
}
}
}
let mut out: Vec<UsageRow> = rows
.into_iter()
.map(|((pool, project), totals)| UsageRow { pool, project, totals })
.collect();
out.sort_by(|a, b| {
a.pool
.cmp(&b.pool)
.then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
});
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, map_store, tmp_paths};
fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String {
serde_json::json!({
"type": "assistant",
"timestamp": ts,
"requestId": req,
"message": {
"id": id,
"model": model,
"usage": {
"input_tokens": input,
"output_tokens": output,
"cache_creation_input_tokens": 1_000_000,
"cache_read_input_tokens": 2_000_000
}
}
})
.to_string()
}
#[test]
fn verbrauch_aggregation_dedup_und_zeitfilter() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
let proj_path = p.projects_dir().join("proj");
let encoded = encode_project_path(&proj_path.to_string_lossy());
let dir = p.pool_dir(&kunde).join("projects").join(&encoded);
fs::create_dir_all(&dir).unwrap();
let lines = [
// Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60)
usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000),
// Retry-Duplikat: gleiche message.id + requestId → zählt nicht
usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000),
// außerhalb des Zeitfensters → zählt nicht
usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5),
// kein usage-Objekt → ignoriert
r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(),
// kaputte Zeile (laufende Session) → ignoriert
r#"{"type":"assist"#.to_string(),
];
fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap();
// Cutoff = jetzt 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig
let rows = usage_stats_in(&p, 365).unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.pool, "kunde");
assert_eq!(r.project, "proj");
assert_eq!(r.totals.input_tokens, 1_000_000);
assert_eq!(r.totals.output_tokens, 1_000_000);
assert_eq!(r.totals.cache_creation_tokens, 1_000_000);
assert_eq!(r.totals.cache_read_tokens, 2_000_000);
assert!((r.totals.cost_usd - 22.35).abs() < 1e-9);
}
#[test]
fn verbrauch_leer_ohne_transcripts() {
let p = tmp_paths();
make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
assert!(usage_stats_in(&p, 30).unwrap().is_empty());
}
#[test]
fn parse_ts_referenz() {
// 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000
assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000));
assert_eq!(parse_ts("kein datum"), None);
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft"
//! auf „läuft nicht mehr", ist die Session beendet → Kontext syncen (Opt-in).
use std::process::Command;
use std::time::Duration;
use crate::domain::paths::Paths;
use crate::domain::project::list_projects_in;
use crate::domain::registry::project_dir;
use crate::domain::settings::sync_on_session_end;
/// Tick-Intervall des Session-Watchers.
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
fn git_root(dir: &std::path::Path) -> Option<std::path::PathBuf> {
dir
.ancestors()
.find(|a| a.join(".git").exists())
.map(|a| a.to_path_buf())
}
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
fn project_state(paths: &Paths) -> Vec<(String, bool)> {
let mut state: Vec<(String, bool)> = list_projects_in(paths)
.map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
.unwrap_or_default();
state.sort();
state
}
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
/// jeder andere Fehler bricht die Kette mit Meldung ab.
fn sync_session_context(repo: &std::path::Path, project: &str) {
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.map_err(|e| format!("git {}: {e}", args.join(" ")))
};
let msg = format!("session-end sync: {project}");
let steps: [&[&str]; 4] = [
&["add", "-A"],
&["commit", "-m", &msg],
&["pull", "--rebase"],
&["push"],
];
for (i, args) in steps.iter().enumerate() {
match run(args) {
Ok(o) if o.status.success() => {}
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
return;
}
Ok(o) => {
eprintln!(
"session-end sync {project}: git {}{}",
args.join(" "),
String::from_utf8_lossy(&o.stderr).trim()
);
return;
}
Err(e) => {
eprintln!("session-end sync {project}: {e}");
return;
}
}
}
}
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
pub(crate) fn spawn_session_watcher() {
std::thread::spawn(move || {
let paths = Paths::real();
let mut state = project_state(&paths);
loop {
std::thread::sleep(WATCH_INTERVAL);
let current = project_state(&paths);
if current == state {
continue;
}
let ended: Vec<&String> = state
.iter()
.filter(|(name, running)| {
*running && !current.iter().any(|(n, r)| n == name && *r)
})
.map(|(name, _)| name)
.collect();
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
// der Watcher verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty() && sync_on_session_end(&paths) {
for project in ended {
if let Ok(dir) = project_dir(&paths, project) {
if let Some(repo) = git_root(&dir) {
sync_session_context(&repo, project);
}
}
}
}
state = current;
}
});
}
+23 -3466
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -138,7 +138,7 @@ fn call_write(req: &Value) -> Value {
fn call_archive(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let dir = req["params"]["arguments"]["dir"].as_str();
match crate::archive_panel_content(&project, dir) {
match crate::domain::archive::archive_panel_content(&project, dir) {
Ok(path) => json!({
"content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
}),
+44
View File
@@ -0,0 +1,44 @@
//! API-Key-Ablage über die keyring-Crate — Linux: Secret Service,
//! Windows: Credential Manager. macOS hat eine eigene Implementierung über
//! das security-CLI (ACL-Gründe, siehe macos.rs).
use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE};
pub(crate) struct KeychainStore;
impl KeychainStore {
fn entry(pool: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new(APIKEY_SERVICE, pool).map_err(|e| e.to_string())
}
}
impl ApikeyStore for KeychainStore {
fn set(&self, pool: &str, key: &str) -> Result<(), String> {
Self::entry(pool)?.set_password(key).map_err(|e| e.to_string())
}
fn has(&self, pool: &str) -> Result<bool, String> {
match Self::entry(pool)?.get_password() {
Ok(_) => Ok(true),
// Kein Eintrag oder kein Store verfügbar → die Key-Datei entscheidet.
Err(
keyring::Error::NoEntry
| keyring::Error::PlatformFailure(_)
| keyring::Error::NoStorageAccess(_),
) => Ok(false),
Err(e) => Err(e.to_string()),
}
}
fn delete(&self, pool: &str) -> Result<(), String> {
match Self::entry(pool)?.delete_credential() {
Ok(())
| Err(
keyring::Error::NoEntry
| keyring::Error::PlatformFailure(_)
| keyring::Error::NoStorageAccess(_),
) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Pro-Projekt-.desktop-Dateien: dash-to-dock/GNOME ordnen dem Fenster
//! (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-Icon zu.
use std::fs;
use std::path::PathBuf;
use crate::domain::paths::Paths;
use crate::domain::project::{read_project_config_in, resolve_icon_path, TerminalConfig};
use crate::domain::registry::load_registry;
/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien.
/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben.
fn applications_dir(paths: &Paths) -> PathBuf {
paths.home.join(".local/share/applications")
}
/// Schreibt/aktualisiert die .desktop eines Projekts. NoDisplay=true hält den
/// App-Starter sauber.
pub(crate) fn write_terminal_desktop(paths: &Paths, project: &str, cfg: &TerminalConfig) {
let dir = applications_dir(paths);
if fs::create_dir_all(&dir).is_err() {
return;
}
let exec = std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(str::to_string))
.unwrap_or_else(|| "ai-control".into());
let icon_line = cfg
.icon
.as_deref()
.map(|i| resolve_icon_path(paths, i))
.filter(|p| p.exists())
.and_then(|p| p.to_str().map(str::to_string))
.map(|p| format!("Icon={p}\n"))
.unwrap_or_default();
let content = format!(
"[Desktop Entry]\nType=Application\nName={project}\nExec={exec} --terminal {project}\n\
{icon_line}StartupWMClass=aicontrol-{project}\nNoDisplay=true\n"
);
let _ = fs::write(dir.join(format!("aicontrol-{project}.desktop")), content);
}
/// Entfernt die .desktop eines Projekts (beim Löschen).
pub(crate) fn remove_terminal_desktop(paths: &Paths, project: &str) {
let _ = fs::remove_file(applications_dir(paths).join(format!("aicontrol-{project}.desktop")));
}
/// Beim App-Start: für jedes registrierte Projekt die .desktop neu schreiben und
/// verwaiste (kein registriertes Projekt mehr) entfernen. Dadurch sind sie immer
/// vorhanden und aktuell, bevor überhaupt ein Terminal startet.
pub(crate) fn sync_all_desktops(paths: &Paths) {
let Ok(reg) = load_registry(paths) else {
return;
};
for project in reg.keys() {
let cfg = read_project_config_in(paths, project)
.map(|c| c.terminal)
.unwrap_or_default();
write_terminal_desktop(paths, project, &cfg);
}
let dir = applications_dir(paths);
if let Ok(entries) = fs::read_dir(&dir) {
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if let Some(project) = name
.strip_prefix("aicontrol-")
.and_then(|n| n.strip_suffix(".desktop"))
{
if !reg.contains_key(project) {
let _ = fs::remove_file(e.path());
}
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//! D-Bus-Relay für die GNOME-Extension: Show()/Hide() steuern das
//! Popup-Fenster. Die Extension ruft Show() beim Panel-Klick und positioniert
//! das Fenster selbst (Anchor::Managed) — unter Wayland darf nur der
//! Compositor fremde Fenster platzieren.
use crate::platform::{Anchor, TrayCallbacks};
struct PopupService {
app: tauri::AppHandle,
cb: TrayCallbacks,
}
#[zbus::interface(name = "com.aicontrol.Popup1")]
impl PopupService {
fn show(&self) {
(self.cb.show)(&self.app, Anchor::Managed);
}
fn hide(&self) {
(self.cb.hide)(&self.app);
}
}
/// Hält den D-Bus-Namen com.aicontrol.Popup, solange die App läuft — die
/// Extension zeigt ihren Panel-Button nur, wenn der Name präsent ist.
pub(super) fn spawn_dbus_service(app: tauri::AppHandle, cb: TrayCallbacks) {
std::thread::spawn(move || {
let serve = move || -> zbus::Result<()> {
let _conn = zbus::blocking::connection::Builder::session()?
.name("com.aicontrol.Popup")?
.serve_at("/com/aicontrol/Popup", PopupService { app, cb })?
.build()?;
loop {
std::thread::park();
}
};
if let Err(e) = serve() {
eprintln!("D-Bus-Popup-Dienst: {e}");
}
});
}
+28
View File
@@ -0,0 +1,28 @@
//! KWin-Script pro Benutzer aktivieren und KWin neu laden — analog zu
//! `gnome-extensions enable`. Systemweit liegt es aus dem Paket. Nötig, weil
//! unter Wayland nur der Compositor unser Popup positionieren darf.
use std::process::Command;
pub(super) fn enable_script() {
let _ = Command::new("kwriteconfig6")
.args([
"--file",
"kwinrc",
"--group",
"Plugins",
"--key",
"ai-control-popupEnabled",
"true",
])
.status();
let _ = Command::new("dbus-send")
.args([
"--session",
"--dest=org.kde.KWin",
"--type=method_call",
"/KWin",
"org.kde.KWin.reconfigure",
])
.status();
}
+111
View File
@@ -0,0 +1,111 @@
//! Linux: Desktop-Erkennung, Secret-Service-Helper, D-Bus-/SNI-Tray.
//! Der Klick-Weg unterscheidet sich je Desktop (GNOME-Extension vs.
//! StatusNotifierItem), das Ergebnis ist überall dasselbe Popup-Fenster.
mod desktop;
mod gnome;
mod kwin;
mod tray_sni;
pub(crate) use desktop::{remove_terminal_desktop, sync_all_desktops, write_terminal_desktop};
use std::path::Path;
use std::process::Command;
use crate::domain::credentials::APIKEY_SERVICE;
use crate::domain::pool::APIKEY_FILE;
use crate::platform::TrayCallbacks;
// ---------- Aktivierung / Fokus / Icons ----------
/// Linux kennt keine kooperative Aktivierungsabtretung.
pub(crate) fn yield_activation(_bundle_id: &str) {}
/// Fenster ohne NSApplication über die Tauri-API fokussieren.
pub(crate) fn activate_self(app: &tauri::AppHandle) {
use tauri::Manager;
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Linux kennt kein Dock-Icon pro Prozess — das Icon kommt über die
/// .desktop-Datei (StartupWMClass/app_id, siehe desktop.rs).
pub(crate) fn set_dock_icon(_path: &str) {}
/// Fremdprozess-Fokus über die PID steht ohne NSRunningApplication nicht zur
/// Verfügung.
pub(crate) fn focus_terminal(_pid: u32) {}
/// Wayland-app_id des Prozesses (muss vor dem GTK-Init gesetzt sein) —
/// darüber ordnen GNOME/dash-to-dock dem Fenster die .desktop-Datei zu.
pub(crate) fn set_app_id(name: &str) {
glib::set_prgname(Some(name));
}
pub(crate) fn reveal_path(path: &Path) {
let target = path.parent().unwrap_or(path);
let _ = Command::new("xdg-open").arg(target).spawn();
}
// ---------- Desktop-Erkennung ----------
/// GNOME-Session? (XDG_CURRENT_DESKTOP enthält „GNOME"). Steuert
/// AppImage-Sperre und Extension-Aktivierung.
pub(crate) fn is_gnome() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_uppercase().contains("GNOME"))
.unwrap_or(false)
}
fn is_kde() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_uppercase().contains("KDE"))
.unwrap_or(false)
}
// ---------- Secrets ----------
/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keyring
/// (dieselben service/username-Attribute, die die keyring-Crate anlegt),
/// bei fehlendem Eintrag aus der Key-Datei.
pub(crate) fn apikey_helper_command(dir: &Path, pool_id: &str) -> String {
format!(
"secret-tool lookup service {APIKEY_SERVICE} username {pool_id} 2>/dev/null || cat '{}'",
dir.join(APIKEY_FILE).display()
)
}
/// claudes OAuth-Credentials liegen unter Linux nicht in einem per
/// security-CLI prüfbaren Keychain — Status/Reset sind hier nicht verfügbar.
pub(crate) fn oauth_keychain_exists(_service: &str) -> Result<bool, String> {
Err("OAuth-Keychain-Status unter Linux nicht verfügbar".into())
}
pub(crate) fn oauth_keychain_delete(_service: &str) -> Result<(), String> {
Err("OAuth-Keychain-Reset unter Linux nicht verfügbar".into())
}
// ---------- Tray ----------
/// Überall dasselbe Ergebnis — Klick aufs Tray-Icon zeigt das Popup, nie ein
/// Menü. Nur der Weg zum Klick unterscheidet sich: GNOME hat kein Tray, daher
/// die Shell-Extension (D-Bus-Relay); KDE/XFCE/Cinnamon haben ein
/// Standard-Tray (StatusNotifierItem), dessen Activate direkt gemeldet wird.
pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> {
if is_gnome() {
// Die Extension liegt systemweit (aus dem Paket), aktiviert wird sie pro
// Benutzer in dconf — das erledigt die im Benutzerkontext laufende App.
let _ = Command::new("gnome-extensions")
.args(["enable", "ai-control-popup@local"])
.status();
gnome::spawn_dbus_service(app.clone(), cb);
} else {
tray_sni::spawn(app.clone(), cb);
// KDE-Wayland: das Popup-Positionieren übernimmt das KWin-Script.
if is_kde() {
kwin::enable_script();
}
}
Ok(())
}
+61
View File
@@ -0,0 +1,61 @@
//! Eigenes StatusNotifierItem (ksni) statt Tauris libappindicator-Tray:
//! libappindicator kann nur ein Menü, wir wollen Linksklick → Popup-Fenster.
//! Activate liefert keine brauchbaren Koordinaten — der Zeiger sitzt beim
//! Klick auf dem Icon, daher Anchor::Cursor.
use crate::platform::{Anchor, TrayCallbacks};
struct AiControlTray {
app: tauri::AppHandle,
icon: Vec<ksni::Icon>,
cb: TrayCallbacks,
}
impl ksni::Tray for AiControlTray {
fn id(&self) -> String {
"ai-control".into()
}
fn title(&self) -> String {
"ai-control".into()
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
self.icon.clone()
}
fn activate(&mut self, _x: i32, _y: i32) {
(self.cb.show)(&self.app, Anchor::Cursor);
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::StandardItem;
vec![StandardItem {
label: "Beenden".into(),
activate: Box::new(|t: &mut AiControlTray| t.app.exit(0)),
..Default::default()
}
.into()]
}
}
/// App-Icon (32×32 PNG) als ARGB32 in Network-Byte-Order für das SNI-IconPixmap.
fn tray_icon_argb() -> Vec<ksni::Icon> {
let img = tauri::image::Image::from_bytes(include_bytes!("../../../icons/32x32.png"))
.expect("32x32.png dekodieren");
let rgba = img.rgba();
let mut data = Vec::with_capacity(rgba.len());
for px in rgba.chunks_exact(4) {
data.extend_from_slice(&[px[3], px[0], px[1], px[2]]);
}
vec![ksni::Icon {
width: img.width() as i32,
height: img.height() as i32,
data,
}]
}
pub(super) fn spawn(app: tauri::AppHandle, cb: TrayCallbacks) {
ksni::TrayService::new(AiControlTray {
app,
icon: tray_icon_argb(),
cb,
})
.spawn();
}
+187
View File
@@ -0,0 +1,187 @@
//! macOS: Aktivierung/Fokus über AppKit (objc2), Dock-Icons pro
//! Terminal-Prozess, Keychain über das security-CLI, nativer Tray.
use std::path::Path;
use std::process::Command;
use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE};
use crate::domain::paths::Paths;
use crate::domain::pool::APIKEY_FILE;
use crate::domain::project::TerminalConfig;
use crate::platform::{Anchor, TrayCallbacks};
// ---------- Aktivierung / Fokus / Icons ----------
/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben). Aktivierung ist
/// seit macOS 14 kooperativ — ohne yield öffnet das Terminal-Fenster hinter
/// der aktiven App.
pub(crate) fn yield_activation(bundle_id: &str) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
let mtm = MainThreadMarker::new().expect("yield_activation läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm)
.yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));
}
/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
pub(crate) fn activate_self(app: &tauri::AppHandle) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use tauri::Manager;
let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm).activate();
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei.
pub(crate) fn set_dock_icon(path: &str) {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread");
// Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal
// ohne eigenes Dock-Icon starten statt den Prozess zu beenden.
let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path))
else {
eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}");
return;
};
// unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig.
unsafe {
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img));
}
}
/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund:
/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit
/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab.
pub(crate) fn focus_terminal(pid: u32) {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication};
let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)
else {
eprintln!("Terminal-Prozess {pid} nicht gefunden");
return;
};
let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term);
term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows);
}
/// Wayland-app_id gibt es nur unter Linux.
pub(crate) fn set_app_id(_name: &str) {}
pub(crate) fn reveal_path(path: &Path) {
let _ = Command::new("open").arg("-R").arg(path).spawn();
}
// ---------- Desktop-Integration (nur Linux substantiell) ----------
pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {}
pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {}
pub(crate) fn sync_all_desktops(_paths: &Paths) {}
// ---------- Secrets ----------
/// macOS über das security-CLI: dessen Einträge tragen /usr/bin/security in
/// der ACL, der apiKeyHelper (liest ebenfalls per security-CLI beim
/// claude-Start) kommt dadurch ohne Keychain-Prompt an den Key. Über das
/// Security-Framework angelegte Einträge (keyring-Crate) würden beim Lesen
/// durchs CLI prompten.
pub(crate) struct KeychainStore;
impl ApikeyStore for KeychainStore {
fn set(&self, pool: &str, key: &str) -> Result<(), String> {
let out = Command::new("security")
.args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).into_owned())
}
}
fn has(&self, pool: &str) -> Result<bool, String> {
// Ohne -w: nur Attribute, kein Secret — kein ACL-Prompt möglich.
let out = Command::new("security")
.args(["find-generic-password", "-s", APIKEY_SERVICE, "-a", pool])
.output()
.map_err(|e| e.to_string())?;
Ok(out.status.success())
}
fn delete(&self, pool: &str) -> Result<(), String> {
// Fehlender Eintrag ist kein Fehler — gelöscht ist gelöscht.
Command::new("security")
.args(["delete-generic-password", "-s", APIKEY_SERVICE, "-a", pool])
.output()
.map_err(|e| e.to_string())?;
Ok(())
}
}
/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keychain,
/// bei fehlendem Eintrag aus der Key-Datei.
pub(crate) fn apikey_helper_command(dir: &Path, pool_id: &str) -> String {
format!(
"security find-generic-password -w -s {APIKEY_SERVICE} -a {pool_id} 2>/dev/null || cat '{}'",
dir.join(APIKEY_FILE).display()
)
}
/// Existiert claudes suffixierter OAuth-Keychain-Eintrag?
pub(crate) fn oauth_keychain_exists(service: &str) -> Result<bool, String> {
let out = Command::new("security")
.args(["find-generic-password", "-s", service])
.output()
.map_err(|e| e.to_string())?;
Ok(out.status.success())
}
/// Löscht claudes suffixierten OAuth-Keychain-Eintrag (Pool-Reset).
pub(crate) fn oauth_keychain_delete(service: &str) -> Result<(), String> {
let out = Command::new("security")
.args(["delete-generic-password", "-s", service])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
// ---------- Tray ----------
/// Natives Tray-Icon in der Menüleiste (Template-Icon). Links-Klick meldet
/// das Icon-Rect; das Popup gehört unter das Menüleisten-Icon.
pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> {
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
let icon = tauri::image::Image::from_bytes(include_bytes!("../../icons/trayTemplate.png"))
.map_err(|e| e.to_string())?;
TrayIconBuilder::with_id("main")
.icon(icon)
.icon_as_template(true)
.on_tray_icon_event(move |tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
rect,
..
} = event
{
(cb.show)(tray.app_handle(), Anchor::IconRect { rect, popup_below: true });
}
})
.build(app)
.map_err(|e| e.to_string())?;
Ok(())
}
+72
View File
@@ -0,0 +1,72 @@
//! OS-Adapter. Dieses Modul definiert die vollständige Liste der
//! plattformabhängigen Operationen — pro Zielsystem existiert genau eine
//! Implementierung mit identischen Signaturen, aufgelöst zur Compile-Zeit
//! (kein dyn Trait). Wer ein neues OS portiert, implementiert diese API:
//!
//! Prozesse terminal_pids, kill_terminal
//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation
//! Fenster/Icons set_dock_icon, set_app_id
//! Dateisystem home_dir, write_secret_file, symlink, reveal_path
//! Shell shell_command
//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command,
//! oauth_keychain_exists, oauth_keychain_delete
//! Desktop write_terminal_desktop, remove_terminal_desktop,
//! sync_all_desktops (nur Linux substantiell, sonst No-ops)
//! Tray init_tray
//!
//! Vertrag Tray/Popup: Das Popup ist auf allen Systemen dasselbe HTML-Fenster
//! (app.rs). Abstrahiert wird NUR der Trigger — init_tray zeigt ein Icon und
//! meldet den Klick mit einem `Anchor`, der sagt, wo das Popup hin soll.
//! Menüs gibt es nicht; einzige Ausnahme ist der SNI-Rechtsklick „Beenden"
//! unter Linux, weil StatusNotifierItem ein Kontextmenü verlangt.
#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub(crate) use unix::{
home_dir, kill_terminal, shell_command, symlink, terminal_pids, write_secret_file,
};
#[cfg(not(target_os = "macos"))]
mod keyring_store;
#[cfg(not(target_os = "macos"))]
pub(crate) use keyring_store::KeychainStore;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
pub(crate) use macos::*;
#[cfg(target_os = "linux")]
pub(crate) mod linux;
#[cfg(target_os = "linux")]
pub(crate) use linux::*;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub(crate) use windows::*;
/// Wo das gemeinsame Popup-Fenster hin soll — mehr weiß app.rs über das OS
/// nicht. Je Zielsystem wird nur eine Teilmenge der Varianten konstruiert.
#[allow(dead_code)]
pub(crate) enum Anchor {
/// Nativer Tray liefert das Icon-Rect (macOS, Windows). `popup_below`
/// entscheidet die Plattform: Menüleiste oben → Popup darunter (macOS),
/// Taskbar unten → Popup darüber (Windows).
IconRect { rect: tauri::Rect, popup_below: bool },
/// SNI/ksni liefert keine Koordinaten — der Zeiger steht beim Klick auf dem
/// Icon (KDE/XFCE/Cinnamon).
Cursor,
/// Positionierung übernimmt der Compositor bzw. die Shell-Extension
/// (GNOME, KDE-Wayland/KWin).
Managed,
}
/// Klick-Relay des Trays an app.rs: `show` zeigt das Popup am Anchor,
/// `hide` versteckt es (nur der GNOME-Toggle ruft hide).
pub(crate) struct TrayCallbacks {
pub(crate) show: Box<dyn Fn(&tauri::AppHandle, Anchor) + Send + Sync>,
#[allow(dead_code)] // macOS/Windows togglen nicht — dort versteckt Fokusverlust.
pub(crate) hide: Box<dyn Fn(&tauri::AppHandle) + Send + Sync>,
}
+59
View File
@@ -0,0 +1,59 @@
//! Unix-Gemeinsames (macOS + Linux): Prozesse, Shell, Dateirechte, Symlinks.
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) fn home_dir() -> PathBuf {
PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt"))
}
/// Kommando für die PTY: Login-Shell aus $SHELL (-l) baut den PATH aus ihren
/// Profil-Dateien auf — shell-agnostisch.
pub(crate) fn shell_command(cmd: &str) -> portable_pty::CommandBuilder {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let mut c = portable_pty::CommandBuilder::new(&shell);
c.args(["-lc", cmd]);
c
}
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
pub(crate) fn terminal_pids(project: &str) -> Vec<u32> {
let out = Command::new("pgrep")
.args(["-f", "--", &format!("--terminal {project}$")])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| l.trim().parse().ok())
.collect(),
Err(_) => Vec::new(),
}
}
/// SIGTERM auf die exakte PID. Der Prozesstod schließt den PTY-Master,
/// claude bekommt HUP und endet — wie beim Schließen des Fensters.
pub(crate) fn kill_terminal(pid: u32) -> Result<(), String> {
let out = Command::new("kill")
.arg(pid.to_string())
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub(crate) fn write_secret_file(path: &Path, content: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("{}: {e}", path.display()))
}
pub(crate) fn symlink(target: &Path, link: &Path) -> Result<(), String> {
std::os::unix::fs::symlink(target, link).map_err(|e| format!("{}: {e}", link.display()))
}
+124
View File
@@ -0,0 +1,124 @@
//! Windows: Alpha-Stand. Implementiert ist der native Tray (wie macOS, Popup
//! über der Taskbar), Fenster-Fokus über Tauri, reveal_path und der
//! Credential Manager über die keyring-Crate (keyring_store.rs). Alles
//! andere sind laut scheiternde Stubs — die offenen Punkte stehen an den
//! Funktionen.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::domain::paths::Paths;
use crate::domain::pool::APIKEY_FILE;
use crate::domain::project::TerminalConfig;
use crate::platform::{Anchor, TrayCallbacks};
// ---------- Prozesse ----------
/// TODO Windows: Prozess-Erkennung (Toolhelp-Snapshot über die Kommandozeile
/// `--terminal <projekt>`). Bis dahin gilt kein Projekt als laufend.
pub(crate) fn terminal_pids(_project: &str) -> Vec<u32> {
Vec::new()
}
pub(crate) fn kill_terminal(_pid: u32) -> Result<(), String> {
Err("Windows: Terminal-Prozesse beenden ist nicht implementiert".into())
}
// ---------- Aktivierung / Fokus / Icons ----------
pub(crate) fn yield_activation(_bundle_id: &str) {}
pub(crate) fn activate_self(app: &tauri::AppHandle) {
use tauri::Manager;
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Windows setzt das Taskbar-Icon über das Fenster-Icon (app.rs), nicht pro
/// Prozess.
pub(crate) fn set_dock_icon(_path: &str) {}
/// TODO Windows: laufendes Terminal-Fenster über die PID nach vorn holen.
pub(crate) fn focus_terminal(_pid: u32) {}
pub(crate) fn set_app_id(_name: &str) {}
pub(crate) fn reveal_path(path: &Path) {
let _ = Command::new("explorer")
.arg(format!("/select,{}", path.display()))
.spawn();
}
// ---------- Dateisystem / Shell ----------
pub(crate) fn home_dir() -> PathBuf {
PathBuf::from(std::env::var("USERPROFILE").expect("USERPROFILE nicht gesetzt"))
}
/// TODO Windows: PATH-Aufbau/Quoting klären (PowerShell vs. cmd); bis dahin
/// cmd /C mit dem konfigurierten Kommando.
pub(crate) fn shell_command(cmd: &str) -> portable_pty::CommandBuilder {
let mut c = portable_pty::CommandBuilder::new("cmd");
c.args(["/C", cmd]);
c
}
/// TODO Windows: Key-Datei mit restriktiver ACL (Pendant zu 0600).
pub(crate) fn write_secret_file(_path: &Path, _content: &str) -> Result<(), String> {
Err("Windows: geschützte Key-Datei ist nicht implementiert — Credential Manager verwenden".into())
}
/// TODO Windows: Symlinks brauchen Developer-Mode oder Privileg; Junctions
/// wären die Alternative für Ordner.
pub(crate) fn symlink(_target: &Path, _link: &Path) -> Result<(), String> {
Err("Windows: Pool-Runtime-Symlinks sind nicht implementiert".into())
}
// ---------- Desktop-Integration (nur Linux substantiell) ----------
pub(crate) fn write_terminal_desktop(_paths: &Paths, _project: &str, _cfg: &TerminalConfig) {}
pub(crate) fn remove_terminal_desktop(_paths: &Paths, _project: &str) {}
pub(crate) fn sync_all_desktops(_paths: &Paths) {}
// ---------- Secrets ----------
/// TODO Windows: Helper-Kommando, das den Key aus dem Credential Manager
/// liest (claude führt es in seiner Shell aus). Bis dahin nur die Key-Datei.
pub(crate) fn apikey_helper_command(dir: &Path, _pool_id: &str) -> String {
format!("type \"{}\"", dir.join(APIKEY_FILE).display())
}
pub(crate) fn oauth_keychain_exists(_service: &str) -> Result<bool, String> {
Err("Windows: OAuth-Keychain-Status ist nicht implementiert".into())
}
pub(crate) fn oauth_keychain_delete(_service: &str) -> Result<(), String> {
Err("Windows: OAuth-Keychain-Reset ist nicht implementiert".into())
}
// ---------- Tray ----------
/// Natives Tray-Icon im System-Tray. Links-Klick meldet das Icon-Rect; die
/// Taskbar liegt unten, das Popup gehört über das Icon.
pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(), String> {
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
let icon = tauri::image::Image::from_bytes(include_bytes!("../../icons/32x32.png"))
.map_err(|e| e.to_string())?;
TrayIconBuilder::with_id("main")
.icon(icon)
.on_tray_icon_event(move |tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
rect,
..
} = event
{
(cb.show)(tray.app_handle(), Anchor::IconRect { rect, popup_below: false });
}
})
.build(app)
.map_err(|e| e.to_string())?;
Ok(())
}
+17 -99
View File
@@ -1,10 +1,15 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use portable_pty::{native_pty_system, ChildKiller, MasterPty, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use crate::domain::paths::{panel_file, Paths};
use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig};
use crate::domain::registry::project_dir;
use crate::domain::settings::claude_command;
/// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label).
pub struct Session {
writer: Box<dyn Write + Send>,
@@ -22,13 +27,13 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// das Terminal-Fenster hinter der aktiven App.
#[tauri::command]
pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
let dir = crate::project_dir(&crate::Paths::real(), &project)?;
let dir = project_dir(&Paths::real(), &project)?;
if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display()));
}
let bundle_id = app.config().identifier.clone();
app
.run_on_main_thread(move || yield_activation_to_bundle(&bundle_id))
.run_on_main_thread(move || crate::platform::yield_activation(&bundle_id))
.map_err(|e| e.to_string())?;
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
std::process::Command::new(exe)
@@ -38,44 +43,6 @@ pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
Ok(())
}
/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).
#[cfg(target_os = "macos")]
fn yield_activation_to_bundle(bundle_id: &str) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
let mtm =
MainThreadMarker::new().expect("yield_activation_to_bundle läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm)
.yieldActivationToApplicationWithBundleIdentifier(&NSString::from_str(bundle_id));
}
/// Linux kennt keine kooperative Aktivierungsabtretung.
#[cfg(target_os = "linux")]
fn yield_activation_to_bundle(_bundle_id: &str) {}
/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
#[cfg(target_os = "macos")]
pub fn activate_self(app: &AppHandle) {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
let mtm = MainThreadMarker::new().expect("activate_self läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm).activate();
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Linux: Fenster ohne NSApplication über die Tauri-API fokussieren.
#[cfg(target_os = "linux")]
pub fn activate_self(app: &AppHandle) {
if let Some(window) = app.webview_windows().values().next() {
window.set_focus().expect("Terminal-Fenster nicht fokussierbar");
}
}
/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
fn theme_background(theme: &str) -> (u8, u8, u8) {
@@ -88,60 +55,13 @@ fn theme_background(theme: &str) -> (u8, u8, u8) {
}
}
/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei.
#[cfg(target_os = "macos")]
pub fn set_dock_icon(path: &str) {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread");
// Nicht ladbar (Datei weg, TCC-geschützter Ordner wie ~/Downloads): Terminal
// ohne eigenes Dock-Icon starten statt den Prozess zu beenden.
let Some(img) = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path))
else {
eprintln!("Dock-Icon nicht ladbar, Terminal startet ohne: {path}");
return;
};
// unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig.
unsafe {
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img));
}
}
/// Linux kennt kein Dock-Icon pro Prozess.
#[cfg(target_os = "linux")]
pub fn set_dock_icon(_path: &str) {}
/// Holt das Terminal-Fenster eines laufenden Projekts in den Vordergrund:
/// aktiviert den Terminal-Prozess über seine PID. Aktivierung ist seit
/// macOS 14 kooperativ — die Tray-App tritt sie vorher ab.
#[cfg(target_os = "macos")]
pub fn focus_terminal(pid: u32) {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationOptions, NSRunningApplication};
let Some(term) = NSRunningApplication::runningApplicationWithProcessIdentifier(pid as i32)
else {
eprintln!("Terminal-Prozess {pid} nicht gefunden");
return;
};
let mtm = MainThreadMarker::new().expect("focus_terminal läuft nicht auf dem Main-Thread");
NSApplication::sharedApplication(mtm).yieldActivationToApplication(&term);
term.activateWithOptions(NSApplicationActivationOptions::ActivateAllWindows);
}
/// Linux: Fremdprozess-Fokus über die PID steht ohne NSRunningApplication
/// nicht zur Verfügung.
#[cfg(target_os = "linux")]
pub fn focus_terminal(_pid: u32) {}
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
/// Ausgaben verloren, bevor der Event-Listener steht.
pub fn build_window(
app: &AppHandle,
project: &str,
cfg: &crate::TerminalConfig,
cfg: &TerminalConfig,
) -> tauri::Result<()> {
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project);
@@ -180,8 +100,8 @@ pub fn term_start(
rows: u16,
cols: u16,
) -> Result<(), String> {
let paths = crate::Paths::real();
let cwd = crate::project_dir(&paths, &project)?;
let paths = Paths::real();
let cwd = project_dir(&paths, &project)?;
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
@@ -190,20 +110,18 @@ pub fn term_start(
// Panel-Kanal: leere Datei anlegen (definierter Startzustand für den
// Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill
// schreibt seinen Entwurf dorthin.
let panel_path = crate::panel_file(&project);
let panel_path = panel_file(&project);
if let Some(parent) = panel_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&panel_path, "");
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let mut cmd = CommandBuilder::new(&shell);
cmd.args(["-lc", &crate::claude_command(&paths)]);
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color");
cmd.env("AI_CONTROL_PANEL", &panel_path);
cmd.env("AI_CONTROL_PROJECT", &project);
if let Some(pool_dir) = crate::project_pool_dir(&project)? {
if let Some(pool_dir) = project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
}
@@ -293,14 +211,14 @@ fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
/// Aktueller Panel-Inhalt (Erstbefüllung eines gerade geöffneten Panel-Fensters).
#[tauri::command]
pub fn panel_read(project: String) -> String {
std::fs::read_to_string(crate::panel_file(&project)).unwrap_or_default()
std::fs::read_to_string(panel_file(&project)).unwrap_or_default()
}
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
/// Änderung als `panel-update` an alle Fenster.
#[tauri::command]
pub fn panel_set(project: String, text: String) -> Result<(), String> {
std::fs::write(crate::panel_file(&project), text).map_err(|e| e.to_string())
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string())
}
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
@@ -312,7 +230,7 @@ pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String>
let _ = w.set_focus();
return Ok(());
}
let cfg = crate::terminal_config(&project)?;
let cfg = terminal_config(&project)?;
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project.as_str());
WebviewWindowBuilder::new(
+6 -4
View File
@@ -28,19 +28,21 @@
],
"linux": {
"deb": {
"depends": ["libayatana-appindicator3-1"],
"files": {
"/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png"
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png",
"/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js"
}
},
"rpm": {
"depends": ["libappindicator-gtk3"],
"files": {
"/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png"
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png",
"/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js"
}
}
},