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:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ai-control",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -30,7 +30,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-control"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"glib",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user