Tray-Menü listet alle Projekte mit Icon: Klick startet („starten") oder holt das laufende Terminal nach vorn („zeigen"); Session-Watcher baut das Menü bei Status-/Listenänderung neu
This commit is contained in:
@@ -33,5 +33,5 @@ keyring = { version = "3", features = ["apple-native", "sync-secret-service", "w
|
|||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
|
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] }
|
||||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||||
|
|||||||
+115
-18
@@ -149,12 +149,14 @@ fn is_git_repo(dir: &PathBuf) -> bool {
|
|||||||
dir.join(".git").exists()
|
dir.join(".git").exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Namen aller Projekte, die gerade laufen — dieselbe Erkennung wie die
|
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
|
||||||
/// Projektliste.
|
/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
|
||||||
fn running_project_set(paths: &Paths) -> HashSet<String> {
|
fn project_state(paths: &Paths) -> Vec<(String, bool)> {
|
||||||
list_projects_in(paths)
|
let mut state: Vec<(String, bool)> = list_projects_in(paths)
|
||||||
.map(|ps| ps.into_iter().filter(|p| p.running).map(|p| p.name).collect())
|
.map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
state.sort();
|
||||||
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
|
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
|
||||||
@@ -180,14 +182,24 @@ fn sync_session_context(home: &PathBuf, project: &str) {
|
|||||||
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
|
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
|
||||||
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
|
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
|
||||||
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
|
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
|
||||||
fn spawn_session_watcher() {
|
/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.
|
||||||
std::thread::spawn(|| {
|
fn spawn_session_watcher(app: tauri::AppHandle) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
let paths = Paths::real();
|
let paths = Paths::real();
|
||||||
let mut running = running_project_set(&paths);
|
let mut state = project_state(&paths);
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(WATCH_INTERVAL);
|
std::thread::sleep(WATCH_INTERVAL);
|
||||||
let current = running_project_set(&paths);
|
let current = project_state(&paths);
|
||||||
let ended: Vec<&String> = running.difference(¤t).collect();
|
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 ein Git-Repo da ist — der Tray
|
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
|
||||||
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||||
if !ended.is_empty()
|
if !ended.is_empty()
|
||||||
@@ -198,7 +210,19 @@ fn spawn_session_watcher() {
|
|||||||
sync_session_context(&paths.home, project);
|
sync_session_context(&paths.home, project);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
running = current;
|
// Menü-Objekte gehören auf den Main-Thread.
|
||||||
|
let handle = app.clone();
|
||||||
|
app
|
||||||
|
.run_on_main_thread(move || {
|
||||||
|
let menu = tray_menu(&handle).expect("Tray-Menü nicht aufbaubar");
|
||||||
|
handle
|
||||||
|
.tray_by_id("main")
|
||||||
|
.expect("Tray-Icon fehlt")
|
||||||
|
.set_menu(Some(menu))
|
||||||
|
.expect("Tray-Menü nicht setzbar");
|
||||||
|
})
|
||||||
|
.expect("Main-Thread nicht erreichbar");
|
||||||
|
state = current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,6 +598,30 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
|
|||||||
Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
|
Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Projekt-Icon fürs Tray-Menü: sips konvertiert (auch ICNS) und skaliert auf
|
||||||
|
/// 36 px; muda rendert Menü-Icons auf macOS mit 18 pt.
|
||||||
|
fn menu_icon(paths: &Paths, project: &str) -> Result<Option<tauri::image::Image<'static>>, String> {
|
||||||
|
let Some(icon) = read_project_config_in(paths, project)?.terminal.icon else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let src = resolve_icon_path(paths, project, &icon);
|
||||||
|
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
|
||||||
|
let out = Command::new("sips")
|
||||||
|
.args(["-s", "format", "png", "-z", "36", "36"])
|
||||||
|
.arg(&src)
|
||||||
|
.arg("--out")
|
||||||
|
.arg(&tmp)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("sips: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
|
||||||
|
}
|
||||||
|
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
Ok(Some(img))
|
||||||
|
}
|
||||||
|
|
||||||
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
||||||
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
|
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
|
||||||
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
|
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
|
||||||
@@ -1463,6 +1511,52 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tray-Menü: pro Projekt ein Eintrag mit Icon; das Label nennt die Aktion —
|
||||||
|
/// „starten" öffnet das Terminal, „zeigen" holt das laufende nach vorn.
|
||||||
|
fn tray_menu(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
) -> Result<tauri::menu::Menu<tauri::Wry>, Box<dyn std::error::Error>> {
|
||||||
|
use tauri::menu::{IconMenuItem, Menu, MenuItem, PredefinedMenuItem};
|
||||||
|
let paths = Paths::real();
|
||||||
|
let menu = Menu::new(app)?;
|
||||||
|
menu.append(&MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?)?;
|
||||||
|
menu.append(&PredefinedMenuItem::separator(app)?)?;
|
||||||
|
let mut projects = list_projects_in(&paths)?;
|
||||||
|
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
for p in projects {
|
||||||
|
let label = if p.running {
|
||||||
|
format!("{} zeigen", p.name)
|
||||||
|
} else {
|
||||||
|
format!("{} starten", p.name)
|
||||||
|
};
|
||||||
|
let icon = menu_icon(&paths, &p.name)?;
|
||||||
|
menu.append(&IconMenuItem::with_id(
|
||||||
|
app,
|
||||||
|
format!("project:{}", p.name),
|
||||||
|
label,
|
||||||
|
true,
|
||||||
|
icon,
|
||||||
|
None::<&str>,
|
||||||
|
)?)?;
|
||||||
|
}
|
||||||
|
menu.append(&PredefinedMenuItem::separator(app)?)?;
|
||||||
|
menu.append(&MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?)?;
|
||||||
|
Ok(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
|
||||||
|
/// sonst startet es. Das Label wechselt, sobald der Watcher den neuen Status sieht.
|
||||||
|
fn start_or_focus(project: &str) {
|
||||||
|
match terminal_pids(project).first() {
|
||||||
|
Some(pid) => terminal::focus_terminal(*pid),
|
||||||
|
None => {
|
||||||
|
if let Err(e) = terminal::open_terminal(project.to_string()) {
|
||||||
|
eprintln!("{project} starten: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
|
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
|
||||||
fn main_builder() -> tauri::Builder<tauri::Wry> {
|
fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -1486,8 +1580,9 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||||
|
|
||||||
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
|
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und
|
||||||
spawn_session_watcher();
|
// hält das Tray-Menü aktuell.
|
||||||
|
spawn_session_watcher(app.handle().clone());
|
||||||
|
|
||||||
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
|
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
|
||||||
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
||||||
@@ -1497,9 +1592,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.visible(false)
|
.visible(false)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?;
|
let menu = tray_menu(app.handle())?;
|
||||||
let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
|
|
||||||
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
|
|
||||||
tauri::tray::TrayIconBuilder::with_id("main")
|
tauri::tray::TrayIconBuilder::with_id("main")
|
||||||
// include_bytes! statt default_window_icon: cargo trackt die Datei,
|
// include_bytes! statt default_window_icon: cargo trackt die Datei,
|
||||||
// neu generierte Icons landen damit sicher im nächsten Build.
|
// neu generierte Icons landen damit sicher im nächsten Build.
|
||||||
@@ -1516,7 +1609,11 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
w.set_focus().unwrap();
|
w.set_focus().unwrap();
|
||||||
}
|
}
|
||||||
"quit" => app.exit(0),
|
"quit" => app.exit(0),
|
||||||
_ => {}
|
id => {
|
||||||
|
if let Some(project) = id.strip_prefix("project:") {
|
||||||
|
start_or_focus(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ 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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
|
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
|
||||||
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
|
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
|
||||||
/// Ausgaben verloren, bevor der Event-Listener steht.
|
/// Ausgaben verloren, bevor der Event-Listener steht.
|
||||||
|
|||||||
Reference in New Issue
Block a user