Linux: nativer SNI-Tray für Nicht-GNOME-Desktops, pfadtragende fs-Fehler, Panic-Tracer

- Tray verzweigt: GNOME -> Extension/D-Bus, KDE/Cinnamon/LXQt -> natives
  SNI-Tray-Icon mit Menue (libappindicator liefert keine Klick-Events)
- fs-Aufrufe nennen bei Fehlern den Pfad statt bloss 'os error 2';
  is_dir-Guard in list_pools_in und Usage-Auswertung
- Panic-Hook schreibt kopierbaren Backtrace (profile.release debug = 1)
- AppImage unter GNOME gesperrt (rfd-Dialog), Verweis auf deb/rpm
- deb/rpm liefern die GNOME-Extension mit; Appindicator als Depends
- Bundle-Targets um deb/rpm/appimage erweitert
This commit is contained in:
marcus hinz
2026-07-12 18:06:35 +02:00
parent b1a2291aef
commit cf844c45da
5 changed files with 214 additions and 55 deletions
+162 -51
View File
@@ -111,7 +111,7 @@ pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), St
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| e.to_string())?)
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!({})
@@ -140,7 +140,7 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
allow.push(serde_json::json!(edit));
}
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
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()))
}
@@ -275,7 +275,8 @@ fn save_registry(
let contracted: std::collections::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| 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()))
}
@@ -423,7 +424,8 @@ fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v["syncOnSessionEnd"] = serde_json::json!(enabled);
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
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()))
}
@@ -702,9 +704,12 @@ fn create_project_full_in(
}
}
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;
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}/**)")];
@@ -721,7 +726,8 @@ fn create_project_full_in(
},
});
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
@@ -814,7 +820,8 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
if dir.exists() {
return Err(format!("Projekt existiert bereits: {name}"));
}
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
fs::create_dir_all(dir.join(".claude"))
.map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?;
register_project(paths, name, &dir)
}
@@ -924,7 +931,7 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
}
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
unregister_project(paths, name)?;
#[cfg(target_os = "linux")]
remove_terminal_desktop(paths, name);
@@ -947,7 +954,8 @@ fn write_project_config_in(
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(project_config_path(paths, project)?, raw + "\n").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()))
}
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
@@ -1116,7 +1124,7 @@ fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String>
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
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);
@@ -1260,14 +1268,18 @@ struct PoolInfo {
fn list_pools_in(paths: &Paths, store: &dyn ApikeyStore) -> Result<Vec<PoolInfo>, String> {
let mut pools = Vec::new();
let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
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| e.to_string())?;
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| e.to_string())?;
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();
@@ -1305,13 +1317,15 @@ fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
if !paths.pools_dir().is_dir() {
return Ok(out);
}
for entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
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| e.to_string())?;
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));
@@ -1330,21 +1344,23 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
}
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| e.to_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| e.to_string())
fs::write(dir.join(POOL_FILE), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display()))
}
fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
fs::write(path, content).map_err(|e| e.to_string())?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
@@ -1367,12 +1383,13 @@ fn init_pool_config(
base.insert(k.clone(), v.clone());
}
}
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
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| 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| e.to_string())?;
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
}
install_panel_skill(dir);
register_mcp_server(dir);
@@ -1533,20 +1550,21 @@ fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
for (name, is_dir) in SYNCED_RUNTIME {
let target = data.join(name);
if is_dir {
fs::create_dir_all(&target).map_err(|e| e.to_string())?;
fs::create_dir_all(&target).map_err(|e| format!("{}: {e}", target.display()))?;
} else {
fs::create_dir_all(&data).map_err(|e| e.to_string())?;
fs::create_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?;
if !target.exists() {
fs::write(&target, "").map_err(|e| e.to_string())?;
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| e.to_string())?;
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| e.to_string())?;
fs::remove_file(&link).map_err(|e| format!("{}: {e}", link.display()))?;
}
std::os::unix::fs::symlink(&target, &link).map_err(|e| e.to_string())?;
std::os::unix::fs::symlink(&target, &link)
.map_err(|e| format!("{}: {e}", link.display()))?;
}
Ok(())
}
@@ -1626,7 +1644,7 @@ fn delete_pool_in(paths: &Paths, store: &dyn ApikeyStore, name: &str) -> Result<
for project in projects_using_pool(paths, name)? {
unassign_pool_in(paths, &project)?;
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
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
@@ -1652,7 +1670,7 @@ fn set_apikey_in(
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| e.to_string())?;
fs::remove_file(&key_path).map_err(|e| format!("{}: {e}", key_path.display()))?;
}
} else {
if !allow_file {
@@ -1667,7 +1685,7 @@ fn set_apikey_in(
.map_err(|e| format!("{}: {e}", settings_path.display()))?;
settings["apiKeyHelper"] = serde_json::json!(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| e.to_string())
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
}
fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
@@ -1814,8 +1832,13 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
let mut seen = HashSet::new();
let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
let pool_entry = pool_entry.map_err(|e| e.to_string())?;
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() {
@@ -1827,8 +1850,10 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
if !projects_root.is_dir() {
continue;
}
for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
let proj_entry = proj_entry.map_err(|e| e.to_string())?;
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;
}
@@ -1837,8 +1862,10 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
// 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| e.to_string())? {
let file = file.map_err(|e| e.to_string())?;
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;
@@ -2152,7 +2179,8 @@ fn set_terminal_font_size(size: u32) -> Result<(), String> {
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v["terminalFontSize"] = serde_json::json!(size);
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
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()))
}
@@ -2169,7 +2197,29 @@ fn link_pool_runtime(pool: String) -> Result<(), String> {
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
/// Panic-Tracer: erzwingt bei jedem Panic einen vollständigen Backtrace
/// (unabhängig von RUST_BACKTRACE), gibt ihn klar abgegrenzt auf stderr aus und
/// schreibt ihn zusätzlich in eine kopierbare Datei (Pfad pro Prozess, damit
/// Haupt- und Terminal-Prozesse sich nicht überschreiben).
fn install_panic_tracer() {
std::panic::set_hook(Box::new(|info| {
let bt = std::backtrace::Backtrace::force_capture();
let dump = format!(
"\n========== ai-control PANIC (pid {}) ==========\n{info}\n\n{bt}\n========== END ==========\n",
std::process::id()
);
eprintln!("{dump}");
let path =
std::env::temp_dir().join(format!("ai-control-panic-{}.log", std::process::id()));
if std::fs::write(&path, &dump).is_ok() {
eprintln!("Backtrace kopierbar in: {}", path.display());
}
}));
}
pub fn run() {
install_panic_tracer();
let mut args = std::env::args().skip(1);
let first = args.next();
@@ -2180,6 +2230,24 @@ pub fn run() {
return;
}
// AppImage ist installationsfrei und kann die GNOME-Popup-Extension nicht
// ausrollen/aktivieren. Unter GNOME daher auf das deb-/rpm-Paket verweisen und
// beenden. KDE/XFCE zeigen das Tray ohne Extension — dort läuft die AppImage.
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() && is_gnome() {
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Warning)
.set_title("ai-control")
.set_description(
"Die AppImage-Fassung wird unter GNOME nicht unterstützt: Das Tray-Icon \
braucht eine GNOME-Shell-Extension, die nur das deb-/rpm-Paket \
installiert. Bitte das deb- bzw. rpm-Paket verwenden.",
)
.set_buttons(rfd::MessageButtons::Ok)
.show();
std::process::exit(1);
}
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
let context = tauri::generate_context!();
match first.as_deref() {
@@ -2331,6 +2399,15 @@ impl PopupService {
}
}
/// GNOME-Session? (XDG_CURRENT_DESKTOP enthält „GNOME"). Steuert AppImage-Sperre
/// und Extension-Aktivierung.
#[cfg(target_os = "linux")]
fn is_gnome() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_uppercase().contains("GNOME"))
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn serve_popup_dbus(app: tauri::AppHandle) -> zbus::Result<()> {
let _conn = zbus::blocking::connection::Builder::session()?
@@ -2455,17 +2532,51 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
.build(app)?;
}
// Linux/GNOME: D-Bus-Relay. Die Shell-Extension (ai-control-popup@local)
// ruft Show() und schiebt das Fenster unter ihren Panel-Button — GNOME
// liefert der App selbst keinen Tray-Klick.
// Linux: GNOME kann kein SNI-Tray, daher dort der Extension/D-Bus-Relay;
// alle anderen Desktops (KDE, Cinnamon, LXQt, …) bekommen ein natives
// Tray-Icon. libappindicator liefert keine Klick-Events, nur ein Menü —
// deshalb Menü statt Klick-Popup wie auf macOS/Windows.
#[cfg(target_os = "linux")]
{
let app_dbus = app.handle().clone();
std::thread::spawn(move || {
if let Err(e) = serve_popup_dbus(app_dbus) {
eprintln!("D-Bus-Popup-Dienst: {e}");
}
});
use tauri::Manager;
if is_gnome() {
// Die Extension liegt systemweit (aus dem Paket), aktiviert wird sie
// pro Benutzer in dconf — das erledigt die im Benutzerkontext
// laufende App. Show() der Extension schiebt das Popup unter den
// Panel-Button.
let _ = Command::new("gnome-extensions")
.args(["enable", "ai-control-popup@local"])
.status();
let app_dbus = app.handle().clone();
std::thread::spawn(move || {
if let Err(e) = serve_popup_dbus(app_dbus) {
eprintln!("D-Bus-Popup-Dienst: {e}");
}
});
} else {
use tauri::menu::MenuItemBuilder;
use tauri::menu::MenuBuilder;
use tauri::tray::TrayIconBuilder;
let open = MenuItemBuilder::with_id("open", "ai-control öffnen").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app).items(&[&open, &quit]).build()?;
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayLinux.png"))?;
TrayIconBuilder::with_id("main")
.icon(icon)
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(w) = app.get_webview_window("popup") {
let _ = w.center();
let _ = w.show();
let _ = w.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
}
}
Ok(())