Linux-Port: GNOME-Popup + Tray, Terminal-Schriftgröße global

- Popup als rahmenloses Fenster mit D-Bus-Relay (popup.html/ts, Popup.vue);
  GNOME-Extension schiebt es unter den Panel-Button
- Terminal: globale Schriftgröße via settings.json (terminalFontSize),
  Zoom mit Ctrl/Cmd +/-/0 im Terminal
- trayLinux-Icon, Capabilities, App.vue/terminal.html angepasst
This commit is contained in:
marcus hinz
2026-07-06 20:08:16 +02:00
parent 85c18eb8f0
commit 1df0da4d48
14 changed files with 1346 additions and 106 deletions
+310 -53
View File
@@ -340,12 +340,12 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
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");
// Nur wenn ein Tray existiert (macOS). Unter Linux pollt das Popup selbst.
if let Some(tray) = handle.tray_by_id("main") {
if let Ok(menu) = tray_menu(&handle) {
let _ = tray.set_menu(Some(menu));
}
}
})
.expect("Main-Thread nicht erreichbar");
state = current;
@@ -556,6 +556,8 @@ fn create_project_full_in(
if todo {
set_todo_in(paths, name, true)?;
}
#[cfg(target_os = "linux")]
write_terminal_desktop(paths, name, &cfg.terminal);
Ok(())
}
@@ -618,7 +620,15 @@ fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)
register_project(paths, &name, &dir)?;
#[cfg(target_os = "linux")]
{
let cfg = read_project_config_in(paths, &name)
.map(|c| c.terminal)
.unwrap_or_default();
write_terminal_desktop(paths, &name, &cfg);
}
Ok(())
}
#[cfg(test)]
@@ -739,7 +749,10 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
}
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
unregister_project(paths, name)
unregister_project(paths, name)?;
#[cfg(target_os = "linux")]
remove_terminal_desktop(paths, name);
Ok(())
}
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
@@ -809,7 +822,10 @@ fn set_terminal_config_in(
}
}
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
write_project_config_in(paths, project, &cfg)?;
#[cfg(target_os = "linux")]
write_terminal_desktop(paths, project, &cfg.terminal);
Ok(())
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
@@ -822,6 +838,78 @@ fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
}
}
/// ~/.local/share/applications — Ziel der pro-Terminal-.desktop-Dateien.
/// paths-abhängig (nicht $HOME direkt), damit Tests in ihr tmp-home schreiben.
#[cfg(target_os = "linux")]
fn applications_dir(paths: &Paths) -> PathBuf {
paths.home.join(".local/share/applications")
}
/// Schreibt/aktualisiert die .desktop eines Projekts: dash-to-dock ordnet dem
/// Fenster (app_id `aicontrol-<projekt>`, via set_prgname) darüber das Projekt-
/// Icon zu. NoDisplay=true hält den App-Starter sauber.
#[cfg(target_os = "linux")]
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).
#[cfg(target_os = "linux")]
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.
#[cfg(target_os = "linux")]
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());
}
}
}
}
}
/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
#[tauri::command]
@@ -837,20 +925,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("icns"));
let png = if is_icns {
let tmp = std::env::temp_dir().join(format!("ai-control-icon-{project}.png"));
let out = std::process::Command::new("sips")
.args(["-s", "format", "png"])
.arg(&path)
.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);
bytes
icns_to_png_bytes(&path, &project)?
} else {
fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?
};
@@ -904,17 +979,49 @@ fn menu_icon(
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}
/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS).
/// Projekt-Icon als 36×36-RGBA. PNG wird von tauri geladen und skaliert;
/// ICNS läuft über sips (nur macOS).
fn project_icon_rgba_36(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<tauri::image::Image<'static>, String> {
let src = resolve_icon_path(paths, icon);
let is_icns = src
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("icns"));
let img = if is_icns {
tauri::image::Image::from_bytes(&icns_to_png_bytes(&src, project)?).map_err(|e| e.to_string())?
} else {
tauri::image::Image::from_path(&src).map_err(|e| format!("{}: {e}", src.display()))?
};
Ok(scale_rgba(&img, 36))
}
/// Nearest-Neighbor-Skalierung eines RGBA-Bildes auf ein Quadrat der Kantenlänge `size`.
fn scale_rgba(img: &tauri::image::Image, size: usize) -> tauri::image::Image<'static> {
let (sw, sh) = (img.width() as usize, img.height() as usize);
let src = img.rgba();
let mut out = vec![0u8; size * size * 4];
for y in 0..size {
for x in 0..size {
let sx = x * sw / size;
let sy = y * sh / size;
let si = (sy * sw + sx) * 4;
let di = (y * size + x) * 4;
out[di..di + 4].copy_from_slice(&src[si..si + 4]);
}
}
tauri::image::Image::new_owned(out, size as u32, size as u32)
}
/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS).
fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result<Vec<u8>, String> {
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)
.args(["-s", "format", "png"])
.arg(src)
.arg("--out")
.arg(&tmp)
.output()
@@ -924,7 +1031,7 @@ fn project_icon_rgba_36(
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
Ok(bytes)
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
@@ -1818,6 +1925,33 @@ fn set_sync_setting(enabled: bool) -> Result<(), String> {
set_sync_on_session_end_in(&Paths::real(), enabled)
}
/// Terminal-Schriftgröße (settings.json: terminalFontSize), ein Wert für alle
/// Terminals. Default 13.
#[tauri::command]
fn terminal_font_size() -> u32 {
fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v["terminalFontSize"].as_u64())
.map(|n| n as u32)
.unwrap_or(13)
}
/// Setzt die Schriftgröße; erhält übrige App-settings.
#[tauri::command]
fn set_terminal_font_size(size: u32) -> Result<(), String> {
let paths = Paths::real();
let path = paths.config_dir().join(APP_SETTINGS_FILE);
let mut v: serde_json::Value = fs::read_to_string(&path)
.ok()
.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())?;
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()))
}
/// Verlinkt die synced Runtime eines bestehenden Pools. Nur bei idlem Pool —
/// sonst würde das Transkript der laufenden Session ersetzt.
#[tauri::command]
@@ -1839,6 +1973,10 @@ pub fn run() {
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
let project = args.next().expect("--terminal braucht einen Projektnamen");
// Linux: eigene Wayland-app_id pro Terminal-Prozess -> eigener Dock-Eintrag
// (muss vor dem GTK-Init stehen). Test, ob set_prgname die app_id setzt.
#[cfg(target_os = "linux")]
glib::set_prgname(Some(format!("aicontrol-{project}").as_str()));
let icon = terminal_config(&project)
.expect("Projekt-Config nicht lesbar")
.icon
@@ -1908,6 +2046,72 @@ fn start_or_focus(app: &tauri::AppHandle, project: &str) {
}
}
/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren.
#[tauri::command]
fn start_or_focus_cmd(app: tauri::AppHandle, project: String) {
start_or_focus(&app, &project);
}
/// Popup-Fußzeile „Öffnen": das Hauptfenster zeigen.
#[tauri::command]
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]
fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}
/// 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.
#[cfg(target_os = "linux")]
struct PopupService {
app: tauri::AppHandle,
}
#[cfg(target_os = "linux")]
#[zbus::interface(name = "com.aicontrol.Popup1")]
impl PopupService {
fn show(&self) {
use tauri::Manager;
let app = self.app.clone();
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.show();
let _ = w.set_focus();
}
});
}
fn hide(&self) {
use tauri::Manager;
let app = self.app.clone();
let win = app.clone();
let _ = app.run_on_main_thread(move || {
if let Some(w) = win.get_webview_window("popup") {
let _ = w.hide();
}
});
}
}
#[cfg(target_os = "linux")]
fn serve_popup_dbus(app: tauri::AppHandle) -> zbus::Result<()> {
let _conn = zbus::blocking::connection::Builder::session()?
.name("com.aicontrol.Popup")?
.serve_at("/com/aicontrol/Popup", PopupService { app })?
.build()?;
loop {
std::thread::park();
}
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
fn main_builder() -> tauri::Builder<tauri::Wry> {
use tauri::Manager;
@@ -1935,38 +2139,77 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
// hält das Tray-Menü aktuell.
spawn_session_watcher(app.handle().clone());
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet.
#[cfg(target_os = "linux")]
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.
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control")
.inner_size(800.0, 600.0)
.visible(false)
.build()?;
.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()?;
let menu = tray_menu(app.handle())?;
tauri::tray::TrayIconBuilder::with_id("main")
// include_bytes! statt default_window_icon: cargo trackt die Datei,
// neu generierte Icons landen damit sicher im nächsten Build.
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/trayTemplate.png"
))?)
// Template-Icon: macOS färbt es passend zur Menüleiste ein.
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let w = app.get_webview_window("main").unwrap();
w.show().unwrap();
w.set_focus().unwrap();
}
"quit" => app.exit(0),
id => {
if let Some(project) = id.strip_prefix("project:") {
start_or_focus(app, project);
// macOS: natives Tray-Menü (rendert Icons pro Eintrag). Unter Linux/GNOME
// übernimmt die Shell-Extension den Panel-Button + Popup — kein Tray hier,
// sonst erschiene das aIC-Icon doppelt.
#[cfg(target_os = "macos")]
{
let menu = tray_menu(app.handle())?;
tauri::tray::TrayIconBuilder::with_id("main")
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/trayTemplate.png"
))?)
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let w = app.get_webview_window("main").unwrap();
w.show().unwrap();
w.set_focus().unwrap();
}
"quit" => app.exit(0),
id => {
if let Some(project) = id.strip_prefix("project:") {
start_or_focus(app, project);
}
}
})
.build(app)?;
}
// Linux/GNOME: rahmenloses Popup-Fenster + 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.
#[cfg(target_os = "linux")]
{
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()?;
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}");
}
})
.build(app)?;
});
}
Ok(())
})
@@ -1976,6 +2219,13 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
api.prevent_close();
window.hide().unwrap();
}
// Popup schließt bei Fokusverlust.
#[cfg(target_os = "linux")]
if let tauri::WindowEvent::Focused(false) = event {
if window.label() == "popup" {
let _ = window.hide();
}
}
})
.invoke_handler(tauri::generate_handler![
list_projects,
@@ -2002,8 +2252,13 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
usage_stats,
stop_project,
restart_project,
start_or_focus_cmd,
open_main_window,
quit_app,
sync_setting,
set_sync_setting,
terminal_font_size,
set_terminal_font_size,
link_pool_runtime,
oauth_login,
keychain_status,
@@ -2036,7 +2291,9 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
list_projects,
project_icon,
pool_label
pool_label,
terminal_font_size,
set_terminal_font_size
])
}
+28
View File
@@ -40,6 +40,7 @@ pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
/// 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;
@@ -50,8 +51,13 @@ fn yield_activation_to_bundle(bundle_id: &str) {
.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;
@@ -62,6 +68,14 @@ pub fn activate_self(app: &AppHandle) {
}
}
/// 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) {
@@ -95,9 +109,14 @@ pub fn set_dock_icon(path: &str) {
}
}
/// 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};
@@ -111,6 +130,11 @@ pub fn focus_terminal(pid: u32) {
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.
@@ -136,6 +160,10 @@ pub fn build_window(
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
// Linux/GNOME: keine GTK-Deko — eigene Titelleiste im Header (terminal.html).
#[cfg(target_os = "linux")]
let builder = builder.decorations(false);
builder.build()?;
Ok(())
}