Tray auf HTML-Popup vereinheitlicht (macOS/Windows), Version 0.2.0

macOS und Windows öffnen jetzt dasselbe rahmenlose Popup-Fenster wie Linux,
ausgelöst über den nativen Tray-Klick und am Icon-rect positioniert; das
native macOS-Menü (tray_menu/menu_icon/project_icon_rgba_36/scale_rgba)
entfällt. Popup-Fenster wird plattformübergreifend gebaut, macOSPrivateApi
für die Transparenz aktiviert, Fokusverlust schließt das Popup überall.
This commit is contained in:
marcus.hinz
2026-07-07 09:09:18 +02:00
parent 8434255101
commit c39330bffb
5 changed files with 67 additions and 172 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ai-control",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"type": "module",
"scripts": {
+1 -1
View File
@@ -30,7 +30,7 @@ dependencies = [
[[package]]
name = "ai-control"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"glib",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "ai-control"
version = "0.1.1"
version = "0.2.0"
description = "Pool- und Session-Verwaltung für Claude Code"
authors = ["marcus.hinz"]
license = "MIT"
@@ -21,7 +21,7 @@ tauri-build = { version = "2.6.3", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = ["tray-icon", "image-png"] }
tauri = { version = "2.11.3", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
+61 -167
View File
@@ -303,12 +303,12 @@ fn sync_session_context(repo: &std::path::Path, project: &str) {
}
}
/// Session-Watcher im Tray-Prozess: pollt die laufenden Projekte; wechselt eines
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
/// Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft"
/// auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
/// Erkennung über das Verschwinden des Prozesses, greift daher auch bei
/// HUP/Kill (im sterbenden Prozess liefe kein Hook mehr).
/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.
fn spawn_session_watcher(app: tauri::AppHandle) {
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
fn spawn_session_watcher() {
std::thread::spawn(move || {
let paths = Paths::real();
let mut state = project_state(&paths);
@@ -326,7 +326,7 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
.map(|(name, _)| name)
.collect();
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
// der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
// der Watcher verlangt kein Repo, er nutzt es nur, falls vorhanden.
if !ended.is_empty() && sync_on_session_end(&paths) {
for project in ended {
if let Ok(dir) = project_dir(&paths, project) {
@@ -336,18 +336,6 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
}
}
}
// Menü-Objekte gehören auf den Main-Thread.
let handle = app.clone();
app
.run_on_main_thread(move || {
// 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;
}
});
@@ -932,90 +920,6 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
}
/// Menü-Icon fürs Tray: ganz links der Status-Punkt (grün = läuft), daneben
/// das Projekt-Icon (sips konvertiert, auch ICNS). Ein RGBA-Bild 56×36 px;
/// muda rendert Menü-Icons mit 18 pt Höhe und proportionaler Breite.
fn menu_icon(
paths: &Paths,
project: &str,
running: bool,
) -> Result<tauri::image::Image<'static>, String> {
const H: usize = 36;
const DOT_W: usize = 20;
const W: usize = DOT_W + H;
let mut canvas = vec![0u8; W * H * 4];
if running {
// macOS-Systemgrün, weicher Rand über Alpha.
let (cx, cy, r) = (10.0f32, 18.0f32, 7.0f32);
for y in 0..H {
for x in 0..DOT_W {
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
let a = (r - d + 0.5).clamp(0.0, 1.0);
if a > 0.0 {
let i = (y * W + x) * 4;
canvas[i..i + 4].copy_from_slice(&[52, 199, 89, (a * 255.0) as u8]);
}
}
}
}
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
// Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
// Meldung, Eintrag erscheint ohne Projekt-Icon.
match project_icon_rgba_36(paths, project, &icon) {
Ok(img) => {
let (iw, ih) = (img.width() as usize, img.height() as usize);
let rgba = img.rgba();
for y in 0..ih.min(H) {
for x in 0..iw.min(H) {
let src_i = (y * iw + x) * 4;
let dst_i = (y * W + DOT_W + x) * 4;
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
}
}
}
Err(e) => eprintln!("Tray-Icon {project}: {e}"),
}
}
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}
/// 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"));
@@ -2011,34 +1915,6 @@ pub fn run() {
}
}
/// Tray-Menü: pro Projekt ein Eintrag — Status-Punkt, Projekt-Icon, Name.
/// Klick startet das Projekt bzw. holt das laufende Terminal 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 icon = menu_icon(&paths, &p.name, p.running)?;
menu.append(&IconMenuItem::with_id(
app,
format!("project:{}", p.name),
&p.name,
true,
Some(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.
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
@@ -2141,9 +2017,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet) und
// hält das Tray-Menü aktuell.
spawn_session_watcher(app.handle().clone());
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
spawn_session_watcher();
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
// damit sie da sind, bevor ein Terminal startet.
@@ -2171,49 +2046,69 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
let main_win = main_win.decorations(false);
main_win.build()?;
// 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")]
// Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Der
// Trigger unterscheidet sich: Linux über die GNOME-Extension/D-Bus,
// macOS/Windows über den nativen Tray-Klick.
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()?;
// macOS/Windows: natives Tray-Icon; Links-Klick öffnet das Popup, am
// Icon-Rect ausgerichtet (macOS unter dem Menüleisten-Icon, Windows drüber).
#[cfg(not(target_os = "linux"))]
{
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);
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))?;
#[cfg(not(target_os = "macos"))]
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?;
let builder = TrayIconBuilder::with_id("main").icon(icon);
#[cfg(target_os = "macos")]
let builder = builder.icon_as_template(true);
builder
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
rect,
..
} = event
{
if let Some(win) = tray.app_handle().get_webview_window("popup") {
let scale = win.scale_factor().unwrap_or(1.0);
let pos = rect.position.to_physical::<f64>(scale);
let size = rect.size.to_physical::<f64>(scale);
let win_w = win.outer_size().map(|s| s.width as f64).unwrap_or(320.0);
// Rechte Popup-Kante an der rechten Icon-Kante ausrichten.
let x = (pos.x + size.width - win_w).max(0.0);
#[cfg(target_os = "macos")]
let y = pos.y + size.height;
#[cfg(not(target_os = "macos"))]
let y = {
let win_h = win.outer_size().map(|s| s.height as f64).unwrap_or(300.0);
(pos.y - win_h).max(0.0)
};
let _ = win.set_position(tauri::PhysicalPosition::new(x, y));
let _ = win.show();
let _ = win.set_focus();
}
}
})
.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.
// 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.
#[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) {
@@ -2231,7 +2126,6 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
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();
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ai-control",
"version": "0.1.1",
"version": "0.2.0",
"identifier": "com.aicontrol.app",
"build": {
"frontendDist": "../dist",
@@ -10,6 +10,7 @@
"beforeBuildCommand": "npm run build"
},
"app": {
"macOSPrivateApi": true,
"windows": [],
"security": {
"csp": null