Terminal öffnet als aktives Fenster: Starter tritt Aktivierung per yieldActivationToApplicationWithBundleIdentifier ab, Terminal-Prozess aktiviert sich beim Ready-Event (kooperative Aktivierung seit macOS 14)

This commit is contained in:
marcus.hinz
2026-07-05 14:09:30 +02:00
parent ec3d2f4fb3
commit 65e9df131f
2 changed files with 59 additions and 57 deletions
+27 -55
View File
@@ -64,35 +64,24 @@ fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
} }
} }
/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout: /// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
/// Unterordner von ~/claude-projects mit .claude-Ordner.
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> { fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
let file = paths.projects_file(); let file = paths.projects_file();
if file.is_file() { if !file.is_file() {
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; return Ok(std::collections::BTreeMap::new());
let map: std::collections::BTreeMap<String, String> =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
return Ok(
map
.into_iter()
.map(|(name, p)| {
let dir = expand_home(paths, &p);
(name, dir)
})
.collect(),
);
} }
let mut map = std::collections::BTreeMap::new(); let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let Ok(entries) = fs::read_dir(paths.projects_dir()) else { let map: std::collections::BTreeMap<String, String> =
return Ok(map); // kein Alt-Layout → keine Projekte serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
}; Ok(
for entry in entries { map
let entry = entry.map_err(|e| e.to_string())?; .into_iter()
if entry.path().join(".claude").is_dir() { .map(|(name, p)| {
map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path()); let dir = expand_home(paths, &p);
} (name, dir)
} })
Ok(map) .collect(),
)
} }
fn save_registry( fn save_registry(
@@ -114,8 +103,7 @@ pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String>
.ok_or_else(|| format!("Projekt nicht registriert: {name}")) .ok_or_else(|| format!("Projekt nicht registriert: {name}"))
} }
/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die /// Nimmt ein Projekt in die Registry auf.
/// per Alt-Layout gefundenen Einträge in projects.json.
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> { fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
let mut reg = load_registry(paths)?; let mut reg = load_registry(paths)?;
if reg.contains_key(name) { if reg.contains_key(name) {
@@ -493,8 +481,6 @@ fn create_project_full_in(
Some(d) => expand_home(paths, d), Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name), None => paths.projects_dir().join(name),
}; };
// Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
// angelegten Ordner sonst schon als bestehendes Projekt sehen.
let mut reg = load_registry(paths)?; let mut reg = load_registry(paths)?;
if reg.contains_key(name) { if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}")); return Err(format!("Projekt existiert bereits: {name}"));
@@ -574,12 +560,7 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
return Err(format!("Projekt existiert bereits: {name}")); 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| e.to_string())?;
// Registrieren nur, wenn die Registry schon materialisiert ist — sonst register_project(paths, name, &dir)
// greift der Alt-Layout-Scan.
if paths.projects_file().is_file() {
register_project(paths, name, &dir)?;
}
Ok(())
} }
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
@@ -619,11 +600,7 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
} }
} }
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
// Vor der Materialisierung gab es keinen Eintrag zu entfernen. unregister_project(paths, name)
if paths.projects_file().is_file() {
unregister_project(paths, name)?;
}
Ok(())
} }
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> { fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
@@ -1454,13 +1431,7 @@ fn remove_project(name: String) -> Result<(), String> {
if is_running(&name) { if is_running(&name) {
return Err(format!("{name} läuft noch — erst beenden")); return Err(format!("{name} läuft noch — erst beenden"));
} }
let paths = Paths::real(); unregister_project(&Paths::real(), &name)
// Alt-Layout erst materialisieren, sonst taucht das Projekt beim
// nächsten Scan wieder auf.
if !paths.projects_file().is_file() {
save_registry(&paths, &load_registry(&paths)?)?;
}
unregister_project(&paths, &name)
} }
#[tauri::command] #[tauri::command]
@@ -1627,7 +1598,7 @@ fn stop_project(project: String) -> Result<(), String> {
/// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das /// Beendet die laufenden Terminal-Prozesse, wartet auf ihr Ende und öffnet das
/// interne Terminal neu. /// interne Terminal neu.
#[tauri::command] #[tauri::command]
fn restart_project(project: String) -> Result<(), String> { fn restart_project(app: tauri::AppHandle, project: String) -> Result<(), String> {
kill_terminals(&project)?; kill_terminals(&project)?;
let deadline = Instant::now() + Duration::from_secs(30); let deadline = Instant::now() + Duration::from_secs(30);
while is_running(&project) { while is_running(&project) {
@@ -1636,7 +1607,7 @@ fn restart_project(project: String) -> Result<(), String> {
} }
std::thread::sleep(Duration::from_millis(250)); std::thread::sleep(Duration::from_millis(250));
} }
terminal::open_terminal(project) terminal::open_terminal(app, project)
} }
#[tauri::command] #[tauri::command]
@@ -1684,11 +1655,12 @@ pub fn run() {
.expect("error while building tauri application") .expect("error while building tauri application")
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt // Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder. // überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
.run(move |_app, event| { .run(move |app, event| {
if let tauri::RunEvent::Ready = event { if let tauri::RunEvent::Ready = event {
if let Some(icon) = icon.as_deref() { if let Some(icon) = icon.as_deref() {
terminal::set_dock_icon(icon); terminal::set_dock_icon(icon);
} }
terminal::activate_self(app);
} }
}); });
} }
@@ -1727,12 +1699,12 @@ fn tray_menu(
} }
/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn, /// 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. /// sonst startet es.
fn start_or_focus(project: &str) { fn start_or_focus(app: &tauri::AppHandle, project: &str) {
match terminal_pids(project).first() { match terminal_pids(project).first() {
Some(pid) => terminal::focus_terminal(*pid), Some(pid) => terminal::focus_terminal(*pid),
None => { None => {
if let Err(e) = terminal::open_terminal(project.to_string()) { if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {
eprintln!("{project} starten: {e}"); eprintln!("{project} starten: {e}");
} }
} }
@@ -1793,7 +1765,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
"quit" => app.exit(0), "quit" => app.exit(0),
id => { id => {
if let Some(project) = id.strip_prefix("project:") { if let Some(project) = id.strip_prefix("project:") {
start_or_focus(project); start_or_focus(app, project);
} }
} }
}) })
+32 -2
View File
@@ -17,13 +17,19 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als /// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein /// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
/// eigenes Dock-Icon bekommt. /// eigenes Dock-Icon bekommt. Vorher tritt der rufende Prozess die
/// Aktivierung ab — Aktivierung ist seit macOS 14 kooperativ, sonst öffnet
/// das Terminal-Fenster hinter der aktiven App.
#[tauri::command] #[tauri::command]
pub fn open_terminal(project: String) -> Result<(), String> { pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
let dir = crate::project_dir(&crate::Paths::real(), &project)?; let dir = crate::project_dir(&crate::Paths::real(), &project)?;
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display())); 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))
.map_err(|e| e.to_string())?;
let exe = std::env::current_exe().map_err(|e| e.to_string())?; let exe = std::env::current_exe().map_err(|e| e.to_string())?;
std::process::Command::new(exe) std::process::Command::new(exe)
.args(["--terminal", &project]) .args(["--terminal", &project])
@@ -32,6 +38,30 @@ pub fn open_terminal(project: String) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Tritt die Aktivierung an den nächsten startenden Prozess mit dieser
/// Bundle-ID ab (der Terminal-Prozess läuft unter derselben).
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));
}
/// Selbst-Aktivierung des frisch gestarteten Terminal-Prozesses (Ready-Event);
/// die Gegenseite hat vorher per yield abgetreten.
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");
}
}
/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in /// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf. /// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
fn theme_background(theme: &str) -> (u8, u8, u8) { fn theme_background(theme: &str) -> (u8, u8, u8) {