SVG-Icons statt ICNS, Popup-Höhe an Monitor begrenzt, GNOME-Shell-Extension für Popup-Positionierung, Bundle-Targets explizit
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+6
-29
@@ -1086,8 +1086,7 @@ fn sync_all_desktops(paths: &Paths) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Icon eines Projekts als PNG-data-URL für die Übersicht; ICNS wird per
|
/// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG).
|
||||||
/// sips nach PNG konvertiert, weil der Browser ICNS nicht rendert.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn project_icon(project: String) -> Result<Option<String>, String> {
|
fn project_icon(project: String) -> Result<Option<String>, String> {
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
@@ -1096,34 +1095,12 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let path = resolve_icon_path(&paths, &icon);
|
let path = resolve_icon_path(&paths, &icon);
|
||||||
let is_icns = path
|
let mime = match path.extension().and_then(|e| e.to_str()) {
|
||||||
.extension()
|
Some(e) if e.eq_ignore_ascii_case("svg") => "image/svg+xml",
|
||||||
.and_then(|e| e.to_str())
|
_ => "image/png",
|
||||||
.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()))?
|
|
||||||
};
|
};
|
||||||
Ok(Some(format!("data:image/png;base64,{}", STANDARD.encode(png))))
|
let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
}
|
Ok(Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes))))
|
||||||
|
|
||||||
/// 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"])
|
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["deb", "rpm", "app", "dmg", "nsis", "msi"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { currentMonitor } from "@tauri-apps/api/window";
|
||||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -11,10 +12,13 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WIDTH = 300;
|
const WIDTH = 300;
|
||||||
|
// Sicherheitsabstand für Panel + Rand; die Extension klemmt zusätzlich exakt.
|
||||||
|
const MARGIN = 64;
|
||||||
|
|
||||||
const projects = ref<Project[]>([]);
|
const projects = ref<Project[]>([]);
|
||||||
const icons = ref<Record<string, string>>({});
|
const icons = ref<Record<string, string>>({});
|
||||||
const wrapRef = ref<HTMLElement>();
|
const wrapRef = ref<HTMLElement>();
|
||||||
|
const maxH = ref(10000);
|
||||||
const win = getCurrentWebviewWindow();
|
const win = getCurrentWebviewWindow();
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -46,12 +50,14 @@ async function quit() {
|
|||||||
// Fenster nur so hoch wie der Inhalt: bei jeder Layout-Änderung nachziehen.
|
// Fenster nur so hoch wie der Inhalt: bei jeder Layout-Änderung nachziehen.
|
||||||
let ro: ResizeObserver | undefined;
|
let ro: ResizeObserver | undefined;
|
||||||
let timer: number;
|
let timer: number;
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
const mon = await currentMonitor();
|
||||||
|
if (mon) maxH.value = Math.floor(mon.size.height / mon.scaleFactor) - MARGIN;
|
||||||
refresh();
|
refresh();
|
||||||
timer = window.setInterval(refresh, 2000);
|
timer = window.setInterval(refresh, 2000);
|
||||||
if (wrapRef.value) {
|
if (wrapRef.value) {
|
||||||
ro = new ResizeObserver(() => {
|
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));
|
if (h > 0) win.setSize(new LogicalSize(WIDTH, h));
|
||||||
});
|
});
|
||||||
ro.observe(wrapRef.value);
|
ro.observe(wrapRef.value);
|
||||||
@@ -64,7 +70,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapRef" class="wrap">
|
<div ref="wrapRef" class="wrap" :style="{ maxHeight: maxH + 'px' }">
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li v-for="p in projects" :key="p.name" class="row" @click="pick(p)">
|
<li v-for="p in projects" :key="p.name" class="row" @click="pick(p)">
|
||||||
<span class="dot" :class="{ on: p.running }"></span>
|
<span class="dot" :class="{ on: p.running }"></span>
|
||||||
@@ -86,6 +92,8 @@ onUnmounted(() => {
|
|||||||
.wrap {
|
.wrap {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
color: #cdd6f4;
|
color: #cdd6f4;
|
||||||
border: 1px solid #313244;
|
border: 1px solid #313244;
|
||||||
@@ -101,6 +109,9 @@ onUnmounted(() => {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@@ -154,6 +165,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.ft {
|
.ft {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 5px 6px 6px;
|
padding: 5px 6px 6px;
|
||||||
border-top: 1px solid #313244;
|
border-top: 1px solid #313244;
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ async function pickIcon() {
|
|||||||
const file = await open({
|
const file = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
directory: false,
|
directory: false,
|
||||||
filters: [{ name: "Icon", extensions: ["png", "icns"] }],
|
filters: [{ name: "Icon", extensions: ["png", "svg"] }],
|
||||||
});
|
});
|
||||||
if (typeof file === "string") settings.value!.icon = file;
|
if (typeof file === "string") settings.value!.icon = file;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user