diff --git a/gnome-shell-extension/ai-control-popup@local/extension.js b/gnome-shell-extension/ai-control-popup@local/extension.js new file mode 100644 index 0000000..eb7630a --- /dev/null +++ b/gnome-shell-extension/ai-control-popup@local/extension.js @@ -0,0 +1,188 @@ +/* GNOME Shell Extension (ESM, GNOME 45+) + * ai-control-popup@local + * Dünner Relay: Panel-Button -> D-Bus Show() an die ai-control-App, dann das + * rahmenlose Popup-Fenster der App unter den Button schieben (Compositor darf das). + */ + +import St from 'gi://St'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; +import Meta from 'gi://Meta'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; + +const DBUS_NAME = 'com.aicontrol.Popup'; +const DBUS_PATH = '/com/aicontrol/Popup'; +const DBUS_IFACE = 'com.aicontrol.Popup1'; +const WIN_TITLE = 'ai-control-popup'; +const ICON_PATH = '/home/marcuh/projects/ai-control/src-tauri/icons/trayLinux.png'; + +export default class AiControlPopupExtension extends Extension { + enable() { + this._proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.DO_NOT_AUTO_START, + null, + DBUS_NAME, + DBUS_PATH, + DBUS_IFACE, + null, + ); + + // Beim Mappen unsichtbar schalten und unmittelbar vor dem nächsten Redraw + // positionieren. Der BEFORE_REDRAW-Later läuft garantiert, bevor der erste + // Frame gemalt wird — der erste sichtbare Frame sitzt damit schon am Zielort, + // kein zentrierter Zwischenframe. + this._mapId = global.window_manager.connect('map', (_wm, actor) => { + const w = actor.meta_window; + if (!w || w.get_title() !== WIN_TITLE) return; + actor.remove_all_transitions(); + actor.opacity = 0; + this._placeBeforeRedraw(actor, w); + }); + + // Panel-Button nur zeigen, solange die App den D-Bus-Namen hält. + this._watchId = Gio.bus_watch_name( + Gio.BusType.SESSION, + DBUS_NAME, + Gio.BusNameWatcherFlags.NONE, + () => this._addButton(), + () => this._removeButton(), + ); + } + + _addButton() { + if (this._button) return; + // dontCreateMenu=true: wir wollen kein Shell-Menü, nur den Klick relayen. + this._button = new PanelMenu.Button(0.0, 'ai-control', true); + const icon = new St.Icon({ + gicon: Gio.icon_new_for_string(ICON_PATH), + icon_size: 36, + }); + this._button.add_child(icon); + this._button.connect('button-press-event', () => { + this._toggle(); + return Clutter.EVENT_STOP; + }); + Main.panel.addToStatusArea('ai-control-popup', this._button, 0, 'right'); + } + + _removeButton() { + this._button?.destroy(); + this._button = null; + } + + _toggle() { + const win = this._findWindow(); + if (win && win.showing_on_its_workspace()) { + this._call('Hide'); + return; + } + // Fenster unsichtbar halten, bis es am Platz sitzt (kein Sprung Mitte->Tray). + const actor = win ? win.get_compositor_private() : null; + if (actor) actor.opacity = 0; + this._call('Show'); + this._positionWhenReady(); + } + + _call(method) { + try { + this._proxy.call_sync(method, null, Gio.DBusCallFlags.NONE, -1, null); + } catch (e) { + logError(e, 'ai-control-popup: D-Bus call failed (App läuft?)'); + } + } + + _position(win) { + const [bx] = this._button.get_transformed_position(); + const bw = this._button.width; + const rect = win.get_frame_rect(); + const wa = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + let x = Math.round(bx + bw - rect.width); + if (x < wa.x) x = wa.x; + // Höhe auf die Arbeitsfläche klemmen, sonst schiebt der Compositor ein zu + // hohes Fenster nach oben aus dem Sichtbereich (oberer Teil verschwindet). + const h = Math.min(rect.height, wa.height); + win.move_resize_frame(true, x, wa.y, rect.width, h); + } + + _placeBeforeRedraw(actor, w) { + const laters = global.compositor.get_laters(); + laters.add(Meta.LaterType.BEFORE_REDRAW, () => { + const rect = w.get_frame_rect(); + // Noch keine echte Größe -> nächster Redraw, bis positionierbar. + if (!rect || rect.width <= 1) return GLib.SOURCE_CONTINUE; + this._position(w); + actor.opacity = 255; + return GLib.SOURCE_REMOVE; + }); + } + + _positionWhenReady() { + if (this._moveId) GLib.source_remove(this._moveId); + let waited = 0; + let held = 0; + // Erst positionieren, wenn das Fenster gemappt ist UND eine echte Größe hat + // (frisch gezeigt liefert get_frame_rect kurz 0 -> off-screen beim 1. Klick). + // Danach ~400 ms weiter nachziehen, falls der Compositor nachplatziert. + let placed = false; + this._moveId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 25, () => { + const win = this._findWindow(); + const actor = win ? win.get_compositor_private() : null; + const rect = win ? win.get_frame_rect() : null; + const ready = win && win.showing_on_its_workspace() && rect && rect.width > 1; + if (ready) { + this._position(win); + // Erst nach dem Positionieren im selben Frame sichtbar schalten. + if (!placed) { + placed = true; + if (actor) actor.opacity = 255; + } + held += 25; + if (held >= 400) { + this._moveId = 0; + return GLib.SOURCE_REMOVE; + } + return GLib.SOURCE_CONTINUE; + } + // Noch nicht platziert -> unsichtbar halten. + if (actor) actor.opacity = 0; + waited += 25; + if (waited > 1500) { + // Nicht platzierbar: trotzdem sichtbar schalten, nie unsichtbar hängen lassen. + if (actor) actor.opacity = 255; + this._moveId = 0; + return GLib.SOURCE_REMOVE; + } + return GLib.SOURCE_CONTINUE; + }); + } + + _findWindow() { + for (const actor of global.get_window_actors()) { + const w = actor.meta_window; + if (w && w.get_title() === WIN_TITLE) return w; + } + return null; + } + + disable() { + if (this._watchId) { + Gio.bus_unwatch_name(this._watchId); + this._watchId = 0; + } + if (this._moveId) { + GLib.source_remove(this._moveId); + this._moveId = 0; + } + if (this._mapId) { + global.window_manager.disconnect(this._mapId); + this._mapId = 0; + } + this._removeButton(); + this._proxy = null; + } +} diff --git a/gnome-shell-extension/ai-control-popup@local/metadata.json b/gnome-shell-extension/ai-control-popup@local/metadata.json new file mode 100644 index 0000000..83fbf7d --- /dev/null +++ b/gnome-shell-extension/ai-control-popup@local/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "ai-control-popup@local", + "name": "ai-control Popup", + "description": "Panel-Button; relayt den Klick per D-Bus an die ai-control-App und schiebt deren Popup-Fenster unter den Button.", + "shell-version": ["48", "49", "50"], + "version": 1 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f86b34d..0261536 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1086,8 +1086,7 @@ fn sync_all_desktops(paths: &Paths) { } } -/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per -/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert. +/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG). #[tauri::command] fn project_icon(project: String) -> Result, String> { use base64::{engine::general_purpose::STANDARD, Engine}; @@ -1096,34 +1095,12 @@ fn project_icon(project: String) -> Result, String> { return Ok(None); }; let path = resolve_icon_path(&paths, &icon); - let is_icns = path - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("icns")); - let png = if is_icns { - icns_to_png_bytes(&path, &project)? - } else { - fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))? + let mime = match path.extension().and_then(|e| e.to_str()) { + Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml", + _ => "image/png", }; - Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png)))) -} - -/// ICNS → PNG-Bytes via sips (nur macOS; Linux hat kein ICNS). -fn icns_to_png_bytes(src: &std::path::Path, project: &str) -> Result, String> { - let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png")); - let out = Command::new("sips") - .args(["-s", "format", "png"]) - .arg(src) - .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); - Ok(bytes) + let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes)))) } /// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e32c3c0..c69a043 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["deb", "rpm", "app", "dmg", "nsis", "msi"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/components/Popup.vue b/src/components/Popup.vue index 6d317cd..bfad3ce 100644 --- a/src/components/Popup.vue +++ b/src/components/Popup.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted, ref } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { currentMonitor } from "@tauri-apps/api/window"; import { LogicalSize } from "@tauri-apps/api/dpi"; interface Project { @@ -11,10 +12,13 @@ interface Project { } const WIDTH = 300; +// Sicherheitsabstand für Panel + Rand; die Extension klemmt zusätzlich exakt. +const MARGIN = 64; const projects = ref([]); const icons = ref>({}); const wrapRef = ref(); +const maxH = ref(10000); const win = getCurrentWebviewWindow(); async function refresh() { @@ -46,12 +50,14 @@ async function quit() { // Fenster nur so hoch wie der Inhalt: bei jeder Layout-Änderung nachziehen. let ro: ResizeObserver | undefined; let timer: number; -onMounted(() => { +onMounted(async () => { + const mon = await currentMonitor(); + if (mon) maxH.value = Math.floor(mon.size.height / mon.scaleFactor) - MARGIN; refresh(); timer = window.setInterval(refresh, 2000); if (wrapRef.value) { ro = new ResizeObserver(() => { - const h = Math.ceil(wrapRef.value!.getBoundingClientRect().height); + const h = Math.min(Math.ceil(wrapRef.value!.getBoundingClientRect().height), maxH.value); if (h > 0) win.setSize(new LogicalSize(WIDTH, h)); }); ro.observe(wrapRef.value); @@ -64,7 +70,7 @@ onUnmounted(() => {