457 lines
16 KiB
TypeScript
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();
|
|
},
|
|
});
|