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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user