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",
|
"name": "ai-control",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -30,7 +30,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ai-control"
|
name = "ai-control"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"glib",
|
"glib",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ai-control"
|
name = "ai-control"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
description = "Pool- und Session-Verwaltung für Claude Code"
|
description = "Pool- und Session-Verwaltung für Claude Code"
|
||||||
authors = ["marcus.hinz"]
|
authors = ["marcus.hinz"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -21,7 +21,7 @@ tauri-build = { version = "2.6.3", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
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-log = "2"
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
tauri-plugin-dialog = "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
|
/// Session-Watcher: pollt die laufenden Projekte; wechselt eines von „läuft"
|
||||||
/// von „läuft" auf „läuft nicht mehr", ist die Session beendet → Kontext syncen.
|
/// 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).
|
||||||
/// Ändert sich Projektliste oder Laufstatus, baut er das Tray-Menü neu auf.
|
/// Das Popup zeigt den Laufstatus selbst über sein 2-s-Polling.
|
||||||
fn spawn_session_watcher(app: tauri::AppHandle) {
|
fn spawn_session_watcher() {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let paths = Paths::real();
|
let paths = Paths::real();
|
||||||
let mut state = project_state(&paths);
|
let mut state = project_state(&paths);
|
||||||
@@ -326,7 +326,7 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
|
|||||||
.map(|(name, _)| name)
|
.map(|(name, _)| name)
|
||||||
.collect();
|
.collect();
|
||||||
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
|
// 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) {
|
if !ended.is_empty() && sync_on_session_end(&paths) {
|
||||||
for project in ended {
|
for project in ended {
|
||||||
if let Ok(dir) = project_dir(&paths, project) {
|
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;
|
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))))
|
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).
|
/// 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> {
|
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 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,
|
/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
|
||||||
/// sonst startet es.
|
/// sonst startet es.
|
||||||
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
|
fn start_or_focus(app: &tauri::AppHandle, project: &str) {
|
||||||
@@ -2141,9 +2017,8 @@ 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) und
|
// Session-Watcher: synct bei Session-Ende (Prozess verschwindet).
|
||||||
// hält das Tray-Menü aktuell.
|
spawn_session_watcher();
|
||||||
spawn_session_watcher(app.handle().clone());
|
|
||||||
|
|
||||||
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
|
// Alle pro-Terminal-.desktop-Dateien neu schreiben + verwaiste entfernen,
|
||||||
// damit sie da sind, bevor ein Terminal startet.
|
// 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);
|
let main_win = main_win.decorations(false);
|
||||||
main_win.build()?;
|
main_win.build()?;
|
||||||
|
|
||||||
// macOS: natives Tray-Menü (rendert Icons pro Eintrag). Unter Linux/GNOME
|
// Rahmenloses Popup-Fenster — dieselbe Optik auf allen Plattformen. Der
|
||||||
// übernimmt die Shell-Extension den Panel-Button + Popup — kein Tray hier,
|
// Trigger unterscheidet sich: Linux über die GNOME-Extension/D-Bus,
|
||||||
// sonst erschiene das aIC-Icon doppelt.
|
// macOS/Windows über den nativen Tray-Klick.
|
||||||
#[cfg(target_os = "macos")]
|
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())?;
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
tauri::tray::TrayIconBuilder::with_id("main")
|
#[cfg(target_os = "macos")]
|
||||||
.icon(tauri::image::Image::from_bytes(include_bytes!(
|
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))?;
|
||||||
"../icons/trayTemplate.png"
|
#[cfg(not(target_os = "macos"))]
|
||||||
))?)
|
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?;
|
||||||
.icon_as_template(true)
|
let builder = TrayIconBuilder::with_id("main").icon(icon);
|
||||||
.menu(&menu)
|
#[cfg(target_os = "macos")]
|
||||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
let builder = builder.icon_as_template(true);
|
||||||
"open" => {
|
builder
|
||||||
let w = app.get_webview_window("main").unwrap();
|
.on_tray_icon_event(|tray, event| {
|
||||||
w.show().unwrap();
|
if let TrayIconEvent::Click {
|
||||||
w.set_focus().unwrap();
|
button: MouseButton::Left,
|
||||||
}
|
button_state: MouseButtonState::Up,
|
||||||
"quit" => app.exit(0),
|
rect,
|
||||||
id => {
|
..
|
||||||
if let Some(project) = id.strip_prefix("project:") {
|
} = event
|
||||||
start_or_focus(app, project);
|
{
|
||||||
|
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)?;
|
.build(app)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux/GNOME: rahmenloses Popup-Fenster + D-Bus-Relay. Die Shell-Extension
|
// Linux/GNOME: D-Bus-Relay. Die Shell-Extension (ai-control-popup@local)
|
||||||
// (ai-control-popup@local) ruft Show() und schiebt das Fenster unter ihren
|
// ruft Show() und schiebt das Fenster unter ihren Panel-Button — GNOME
|
||||||
// Panel-Button — GNOME liefert der App selbst keinen Tray-Klick.
|
// liefert der App selbst keinen Tray-Klick.
|
||||||
#[cfg(target_os = "linux")]
|
#[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();
|
let app_dbus = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Err(e) = serve_popup_dbus(app_dbus) {
|
if let Err(e) = serve_popup_dbus(app_dbus) {
|
||||||
@@ -2231,7 +2126,6 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
window.hide().unwrap();
|
window.hide().unwrap();
|
||||||
}
|
}
|
||||||
// Popup schließt bei Fokusverlust.
|
// Popup schließt bei Fokusverlust.
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if let tauri::WindowEvent::Focused(false) = event {
|
if let tauri::WindowEvent::Focused(false) = event {
|
||||||
if window.label() == "popup" {
|
if window.label() == "popup" {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ai-control",
|
"productName": "ai-control",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"identifier": "com.aicontrol.app",
|
"identifier": "com.aicontrol.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"beforeBuildCommand": "npm run build"
|
"beforeBuildCommand": "npm run build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"macOSPrivateApi": true,
|
||||||
"windows": [],
|
"windows": [],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
|
|||||||
Reference in New Issue
Block a user