Files
Thinkpad-Hyprland-Dotfiles/config/hypr/ags/package-manager.tsx
2026-04-28 18:25:20 +02:00

457 lines
16 KiB
TypeScript

import app from "ags/gtk4/app";
import { Astal, Gtk } from "ags/gtk4";
import { execAsync, subprocess, Process } from "ags/process";
import GLib from "gi://GLib";
import css from "./package-manager.css";
const WINDOW_MARGIN_TOP = 48;
const ESC_KEYVAL = 65307;
const RUNNER = `${GLib.get_home_dir()}/.config/hypr/Scripts/ags-package-runner.py`;
type Helper = "pacman" | "paru";
type PackageResult = {
repo: string;
name: string;
version: string;
installed: boolean;
description: string;
};
let helper: Helper = "paru";
let query = "";
let results: PackageResult[] = [];
let busy = false;
let statusMessage = "Suchbegriff eingeben und Enter druecken.";
let errorMessage = "";
let activeProcess: Process | null = null;
let operationTitle = "";
let operationOutput = "";
let operationInput = "";
let secretInput = false;
let lastOutput = "";
function shQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function notify(message: string) {
execAsync(["notify-send", "Pakete", message]).catch(console.error);
}
function runShell(command: string) {
return execAsync(["bash", "-lc", command]);
}
function setBusy(value: boolean) {
busy = value;
rebuild();
}
function appendOperationOutput(data: string) {
operationOutput = `${operationOutput}${data}`.slice(-30000);
lastOutput = data;
secretInput = /(\[sudo\].*password|passwort|password).*:/i.test(data);
rebuild();
}
function commandLabel(command: string[]) {
return command.map(shQuote).join(" ");
}
function helperAvailable(nextHelper: Helper) {
return runShell(`command -v ${nextHelper} >/dev/null 2>&1`);
}
function parseSearch(output: string) {
const parsed: PackageResult[] = [];
const lines = output.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const header = lines[index];
const match = header.match(/^([^/\s]+)\/([^\s]+)\s+([^\s]+)(?:\s+\[(installed[^\]]*)\])?/);
if (!match) {
continue;
}
const descriptionLines: string[] = [];
let next = index + 1;
while (next < lines.length && /^\s+/.test(lines[next])) {
descriptionLines.push(lines[next].trim());
next += 1;
}
parsed.push({
repo: match[1],
name: match[2],
version: match[3],
installed: Boolean(match[4]),
description: descriptionLines.join(" ") || "Keine Beschreibung.",
});
index = next - 1;
}
return parsed.slice(0, 80);
}
function searchPackages() {
const term = query.trim();
if (!term) {
results = [];
statusMessage = "Suchbegriff fehlt.";
rebuild();
return;
}
setBusy(true);
errorMessage = "";
statusMessage = `Suche mit ${helper} nach "${term}"...`;
helperAvailable(helper)
.then(() => runShell(`${helper} -Ss ${shQuote(term)} 2>/dev/null || true`))
.then(output => {
results = parseSearch(output);
statusMessage = results.length
? `${results.length} Treffer fuer "${term}" mit ${helper}.`
: `Keine Treffer fuer "${term}".`;
})
.catch(error => {
console.error(error);
results = [];
errorMessage = `${helper} ist nicht verfuegbar oder die Suche ist fehlgeschlagen.`;
statusMessage = "Suche fehlgeschlagen.";
})
.finally(() => {
setBusy(false);
});
}
function startOperation(title: string, command: string[]) {
if (activeProcess) {
statusMessage = "Es laeuft bereits ein Paketprozess.";
rebuild();
return;
}
operationTitle = title;
operationOutput = `$ ${commandLabel(command)}\n\n`;
operationInput = "";
errorMessage = "";
statusMessage = `${title} laeuft in AGS.`;
busy = true;
const process = subprocess(
[RUNNER, "--", ...command],
line => {
try {
const event = JSON.parse(line);
if (event.type === "out") {
appendOperationOutput(event.data || "");
} else if (event.type === "exit") {
const suffix = event.signaled ? `Signal ${event.code}` : `Exit ${event.code}`;
appendOperationOutput(`\n[${suffix}]\n`);
}
} catch {
appendOperationOutput(`${line}\n`);
}
},
line => appendOperationOutput(`${line}\n`),
);
activeProcess = process;
process.connect("exit", (_, code: number, signaled: boolean) => {
activeProcess = null;
busy = false;
secretInput = false;
statusMessage = signaled
? `${title} wurde beendet.`
: code === 0
? `${title} abgeschlossen.`
: `${title} fehlgeschlagen (Exit ${code}).`;
notify(statusMessage);
rebuild();
});
rebuild();
}
function sendOperationInput(value = operationInput, allowEmpty = false) {
if (!activeProcess || (!allowEmpty && value.length === 0)) {
return;
}
activeProcess.writeAsync(`${JSON.stringify({ type: "input", data: `${value}\n` })}\n`).catch(error => {
console.error(error);
errorMessage = "Eingabe konnte nicht gesendet werden.";
rebuild();
});
operationInput = "";
secretInput = false;
rebuild();
}
function yesAnswer() {
return /[\[(][YyJj]\/[Nn][\])]/.test(lastOutput) ? "" : "y";
}
function sendYesAnswer() {
const answer = yesAnswer();
appendOperationOutput(answer ? `\n> ${answer}\n` : "\n> Enter\n");
sendOperationInput(answer, true);
}
function sendNoAnswer() {
appendOperationOutput("\n> n\n");
sendOperationInput("n");
}
function cancelOperation() {
if (!activeProcess) {
return;
}
activeProcess.writeAsync(`${JSON.stringify({ type: "signal", signal: 15 })}\n`).catch(console.error);
statusMessage = "Abbruch angefordert.";
rebuild();
}
function installPackage(pkg: PackageResult) {
const command = helper === "pacman"
? ["sudo", "pacman", "-S", pkg.name]
: ["paru", "-S", pkg.name];
notify(`Installation gestartet: ${pkg.name}`);
startOperation(`Installation: ${pkg.name}`, command);
}
function updateSystem() {
const command = helper === "pacman"
? ["sudo", "pacman", "-Syu"]
: ["paru", "-Syu"];
notify(`Update gestartet mit ${helper}.`);
startOperation(`Systemupdate mit ${helper}`, command);
}
function HelperButton({ id, label }: { id: Helper; label: string }) {
return (
<button
class={helper === id ? "tool-button active" : "tool-button"}
onClicked={() => {
helper = id;
statusMessage = `Aktiver Helper: ${helper}`;
rebuild();
}}
label={label}
/>
);
}
function PackageRow({ pkg }: { pkg: PackageResult }) {
return (
<box class="package-row" spacing={10}>
<box orientation={Gtk.Orientation.VERTICAL} spacing={4} hexpand>
<box spacing={8}>
<label class="package-name" xalign={0} label={pkg.name} />
<label class="repo-pill" label={pkg.repo} />
{pkg.installed ? <label class="installed-pill" label="installed" /> : <box />}
</box>
<label class="package-meta" xalign={0} label={pkg.version} />
<label class="package-desc" xalign={0} wrap label={pkg.description} />
</box>
<button class="button primary" sensitive={!busy} onClicked={() => installPackage(pkg)} label="Installieren" />
</box>
);
}
function OperationPanel() {
if (!operationTitle && !operationOutput) {
return <box />;
}
return (
<box class="operation-panel" orientation={Gtk.Orientation.VERTICAL} spacing={8}>
<box class="operation-header" spacing={8}>
<label class="operation-title" xalign={0} hexpand label={operationTitle || "Paketprozess"} />
<button class="button danger" sensitive={Boolean(activeProcess)} onClicked={cancelOperation} label="Abbrechen" />
</box>
<scrolledwindow
class="operation-scroll"
hexpand
vexpand
hscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
>
<label class="operation-output" xalign={0} yalign={0} wrap selectable label={operationOutput || "Warte auf Ausgabe..."} />
</scrolledwindow>
<box class="operation-input-row" spacing={8}>
<entry
hexpand
sensitive={Boolean(activeProcess)}
visibility={!secretInput}
text={operationInput}
placeholderText={secretInput ? "Passwort eingeben..." : "Antwort eingeben..."}
onChanged={entry => {
operationInput = entry.get_text();
}}
onActivate={entry => {
operationInput = entry.get_text();
sendOperationInput();
}}
/>
<button class="button" sensitive={Boolean(activeProcess)} onClicked={sendYesAnswer} label="Ja" />
<button class="button" sensitive={Boolean(activeProcess)} onClicked={sendNoAnswer} label="Nein" />
<button class="button primary" sensitive={Boolean(activeProcess)} onClicked={() => sendOperationInput()} label="Senden" />
</box>
</box>
);
}
function PackageManagerWindow() {
return (
<window
name="package-manager"
namespace="package-manager"
class="package-window"
visible
keymode={Astal.Keymode.EXCLUSIVE}
anchor={Astal.WindowAnchor.TOP}
application={app}
>
<Gtk.EventControllerKey onKeyPressed={(_, keyval) => {
if (keyval === ESC_KEYVAL) {
app.quit();
return true;
}
return false;
}} />
<box class="package-panel" orientation={Gtk.Orientation.VERTICAL} spacing={14} marginTop={WINDOW_MARGIN_TOP}>
<box class="header">
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
<label class="title" xalign={0} label="Paket Installation / Updates" />
<label class="subtitle" xalign={0} label="Pacman und AUR Suche, Installation direkt in AGS." />
</box>
<button class="icon-button close" tooltipText="Schliessen" onClicked={() => app.quit()}>
<label label="" />
</button>
</box>
<box class="toolbar" spacing={8}>
<entry
hexpand
placeholderText="Paket suchen, z.B. firefox, docker, obsidian..."
onChanged={entry => {
query = entry.get_text();
}}
onActivate={entry => {
query = entry.get_text();
searchPackages();
}}
/>
<button class="button primary" sensitive={!busy} onClicked={searchPackages} label={busy ? "Suche..." : "Suchen"} />
</box>
<box class="toolbar" spacing={8}>
<HelperButton id="paru" label="paru" />
<HelperButton id="pacman" label="pacman" />
<button class="button" sensitive={!busy} onClicked={updateSystem} label="System updaten" />
</box>
<box class="status-strip">
<label class={errorMessage ? "error" : "muted"} xalign={0} hexpand label={errorMessage || statusMessage} />
</box>
<OperationPanel />
<scrolledwindow
class="results-scroll"
hexpand
vexpand
hscrollbarPolicy={Gtk.PolicyType.NEVER}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
>
{results.length
? (
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
{results.map(pkg => <PackageRow pkg={pkg} />)}
</box>
)
: (
<box class="empty">
<label hexpand xalign={0.5} label="Noch keine Suchergebnisse." />
</box>
)}
</scrolledwindow>
</box>
</window>
);
}
function rebuild() {
const win = app.get_window("package-manager");
if (!win) {
return;
}
win.set_child(
<box class="package-panel" orientation={Gtk.Orientation.VERTICAL} spacing={14} marginTop={WINDOW_MARGIN_TOP}>
<box class="header">
<box orientation={Gtk.Orientation.VERTICAL} hexpand>
<label class="title" xalign={0} label="Paket Installation / Updates" />
<label class="subtitle" xalign={0} label="Pacman und AUR Suche, Installation direkt in AGS." />
</box>
<button class="icon-button close" tooltipText="Schliessen" onClicked={() => app.quit()}>
<label label="" />
</button>
</box>
<box class="toolbar" spacing={8}>
<entry
hexpand
text={query}
placeholderText="Paket suchen, z.B. firefox, docker, obsidian..."
onChanged={entry => {
query = entry.get_text();
}}
onActivate={entry => {
query = entry.get_text();
searchPackages();
}}
/>
<button class="button primary" sensitive={!busy} onClicked={searchPackages} label={busy ? "Suche..." : "Suchen"} />
</box>
<box class="toolbar" spacing={8}>
<HelperButton id="paru" label="paru" />
<HelperButton id="pacman" label="pacman" />
<button class="button" sensitive={!busy} onClicked={updateSystem} label="System updaten" />
</box>
<box class="status-strip">
<label class={errorMessage ? "error" : "muted"} xalign={0} hexpand label={errorMessage || statusMessage} />
</box>
<OperationPanel />
<scrolledwindow
class="results-scroll"
hexpand
vexpand
hscrollbarPolicy={Gtk.PolicyType.NEVER}
vscrollbarPolicy={Gtk.PolicyType.AUTOMATIC}
>
{results.length
? (
<box orientation={Gtk.Orientation.VERTICAL} spacing={8}>
{results.map(pkg => <PackageRow pkg={pkg} />)}
</box>
)
: (
<box class="empty">
<label hexpand xalign={0.5} label="Noch keine Suchergebnisse." />
</box>
)}
</scrolledwindow>
</box> as Gtk.Widget
);
}
app.start({
css,
instanceName: "package-manager",
main() {
PackageManagerWindow();
},
});