diff --git a/files.js b/files.js
index 8e55ccd4e995..8a5239558bbd 100644
--- a/files.js
+++ b/files.js
@@ -26,6 +26,7 @@ const info = {
"playground/notifications-receiver.js",
"playground/journal.jsx",
"playground/remote.tsx",
+ "playground/dnf5daemon.js",
"selinux/selinux.js",
"shell/shell.jsx",
@@ -117,6 +118,7 @@ const info = {
"playground/notifications-receiver.html",
"playground/journal.html",
"playground/remote.html",
+ "playground/dnf5daemon.html",
"selinux/index.html",
diff --git a/pkg/lib/dnf5daemon.js b/pkg/lib/dnf5daemon.js
new file mode 100644
index 000000000000..80ff2a86e82b
--- /dev/null
+++ b/pkg/lib/dnf5daemon.js
@@ -0,0 +1,150 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2025 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import cockpit from "cockpit";
+import { superuser } from 'superuser';
+
+const _ = cockpit.gettext;
+
+let _dbus_client = null;
+
+/**
+ * Get dnf5daemon D-Bus client
+ *
+ * This will get lazily initialized and re-initialized after dnf5daemon
+ * disconnects (due to a crash or idle timeout).
+ */
+function dbus_client() {
+ if (_dbus_client === null) {
+ _dbus_client = cockpit.dbus("org.rpm.dnf.v0", { superuser: "try", track: true });
+ _dbus_client.addEventListener("close", () => {
+ console.log("dnf5daemon went away from D-Bus");
+ _dbus_client = null;
+ });
+ }
+
+ return _dbus_client;
+}
+
+// Reconnect when privileges change
+superuser.addEventListener("changed", () => { _dbus_client = null });
+
+/**
+ * Call a dnf5daemon method
+ */
+export function call(objectPath, iface, method, args, opts) {
+ return dbus_client().call(objectPath, iface, method, args, opts);
+}
+
+/**
+ * Figure out whether dnf5daemon is available and usable
+ */
+export function detect() {
+ function dbus_detect() {
+ return call("/org/rpm/dnf/v0", "org.freedesktop.DBus.Peer",
+ "Ping", [])
+ .then(() => true,
+ () => false);
+ }
+
+ return cockpit.spawn(["findmnt", "-T", "/usr", "-n", "-o", "VFS-OPTIONS"])
+ .then(options => {
+ if (options.split(",").indexOf("ro") >= 0)
+ return false;
+ else
+ return dbus_detect();
+ })
+ .catch(dbus_detect);
+}
+
+// TODO: close_session needs to be handled
+// handle
+// Cannot open new session - maximal number of simultaneously opened sessions achieved
+export async function check_missing_packages(names, progress_cb) {
+ const data = {
+ missing_ids: [],
+ missing_names: [],
+ unavailable_names: [],
+ };
+
+ if (names.length === 0)
+ return data;
+
+ function open_session() {
+ return call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager",
+ "open_session", [{}]);
+ }
+
+ function close_session(session) {
+ return call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager",
+ "close_session", [session]);
+ }
+
+ async function refresh(session) {
+ // refresh dnf5daemon state
+ await call(session, "org.rpm.dnf.v0.Base", "read_all_repos", []);
+ const resolve_results = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]);
+ console.log(resolve_results);
+ const transaction_results = await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
+ console.log(transaction_results);
+ }
+
+ async function list(session) {
+ const package_attrs = ["name", "version", "release", "arch"];
+
+ const result = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [{ package_attrs: { t: 'as', v: package_attrs }, scope: { t: 's', v: "installed" }, patterns: { t: 'as', v: ['bash'] } }]);
+ console.log("list result", result);
+ for (const [pkg] of result) {
+ console.log("pkg", pkg);
+ data.missing_ids.push(pkg.id.v);
+ data.missing_names.push(pkg.name.v);
+ }
+ }
+
+ function signal_emitted(path, iface, signal, args) {
+ console.log("signal_emitted", path, iface, signal, args);
+ if (progress_cb)
+ progress_cb(signal);
+ }
+
+ // TODO: decorator / helper for opening a session?
+ let session;
+ const client = dbus_client();
+ const subscription = client.subscribe({}, signal_emitted);
+
+ try {
+ [session] = await open_session();
+ console.log(session);
+ await refresh(session);
+ await list(session);
+
+ await close_session(session);
+ } catch (err) {
+ console.warn(err);
+ if (session)
+ await close_session(session);
+ }
+
+ subscription.remove();
+ console.log(subscription);
+ // HACK: close the client so subscribe matches are actually dropped.
+ client.close();
+
+ return data;
+}
diff --git a/pkg/playground/dnf5daemon.html b/pkg/playground/dnf5daemon.html
new file mode 100644
index 000000000000..4c7dad8f9b4e
--- /dev/null
+++ b/pkg/playground/dnf5daemon.html
@@ -0,0 +1,14 @@
+
+
+
+
+ dnf5daemon
+
+
+
+
+
+
+
+
+
diff --git a/pkg/playground/dnf5daemon.js b/pkg/playground/dnf5daemon.js
new file mode 100644
index 000000000000..b5afcbeed074
--- /dev/null
+++ b/pkg/playground/dnf5daemon.js
@@ -0,0 +1,94 @@
+import cockpit from "cockpit";
+import React from 'react';
+import { createRoot } from "react-dom/client";
+import 'cockpit-dark-theme'; // once per page
+
+import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
+import { Card, CardBody, CardTitle } from '@patternfly/react-core/dist/esm/components/Card/index.js';
+import { Content } from "@patternfly/react-core/dist/esm/components/Content/index.js";
+import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js";
+import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
+import {
+ CheckIcon,
+ ExclamationCircleIcon,
+} from '@patternfly/react-icons';
+
+import * as PK from "../lib/dnf5daemon.js";
+
+import '../lib/patternfly/patternfly-6-cockpit.scss';
+import "../../node_modules/@patternfly/patternfly/components/Page/page.css";
+
+const DnfPage = ({ exists }) => {
+ const [isRefreshing, setRefreshing] = React.useState(false);
+ const [events, setEvents] = React.useState([]);
+ const [refreshData, setRefreshData] = React.useState({});
+
+ const progressCallback = (signal_text) => {
+ setEvents(prevState => [...prevState, signal_text]);
+ };
+
+ const refreshDatabase = async () => {
+ setEvents([]);
+ setRefreshData({});
+ setRefreshing(true);
+ const data = await PK.check_missing_packages("bash", progressCallback);
+ console.log(data);
+ setRefreshData(data);
+ console.log(data);
+ setRefreshing(false);
+ };
+
+ const cancelRefreshDatabase = () => {
+ setRefreshing(false);
+ };
+ console.log("events", events);
+
+ return (
+
+
+
+ dnf5daemon example
+ daemon available?: { exists ? : }
+
+ Refresh database
+
+
+ {isRefreshing && }
+
+
+ {refreshData.missing_names && refreshData.missing_names.length !== 0 &&
+
+ Missing packages
+
+ {refreshData.missing_names.map((name, idx) => {
+ return }>{name};
+ })}
+
+
+
+ }
+ {events.length !== 0 &&
+
+ Events
+
+ {events.map((evt, idx) => {
+ return }>{evt};
+ })}
+
+
+ }
+
+
+
+
+
+ );
+};
+
+document.addEventListener("DOMContentLoaded", async () => {
+ const dnf5daemon_exists = await PK.detect();
+ console.log("dnf5daemon", dnf5daemon_exists);
+
+ const root = createRoot(document.getElementById("dnf5daemon"));
+ root.render();
+});
diff --git a/pkg/playground/manifest.json b/pkg/playground/manifest.json
index 372902c9ec0d..bb51993c5f85 100644
--- a/pkg/playground/manifest.json
+++ b/pkg/playground/manifest.json
@@ -44,6 +44,9 @@
},
"remote": {
"label": "Remote channel"
+ },
+ "dnf5daemon": {
+ "label": "dnf5daemon example"
}
},
"preload": [ "preloaded" ],