* Copyright (C) 2025 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import { superuser } from 'superuser';
import { InstallProgressCB, MissingPackages, PackageManager, ProgressCB, ResolveError, InstallProgressType, UpdateDetail, Update, Severity, History, UpdateProgressHandlers, TransactionExitStatus, RunningUpdate } from './packagemanager-abstract';
let _dbus_client: cockpit.DBusClient | null = 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.warn("dnf5daemon went away from D-Bus");
_dbus_client = null;
});
}
return _dbus_client;
}
superuser.addEventListener("changed", () => {
if (_dbus_client)
_dbus_client.close();
_dbus_client = null;
});
async function open_session(): Promise<string> {
const [session] = await call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager",
"open_session", [{}]) as string[];
return session;
}
function close_session(session: string) {
return call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager",
"close_session", [session]);
}
interface ListPackage {
arch: { t: "s", v: string }
download_size: { t: "t", "v": number }
id: { t: "i"; v: number };
is_installed: { t: "b"; v: boolean };
name: { t: "s"; v: string };
release: { t: "s"; v: string };
version: { t: "s"; v: string };
nevra?: { t: "s"; v: string };
}
interface CollectionPackage {
a: { t: "s"; v: string };
e: { t: "s"; v: string };
n: { t: "s"; v: string };
nevra: { t: "s"; v: string };
r: { t: "s"; v: string };
v: { t: "s"; v: string };
}
type AdvisoryType = "bugfix" | "enhancement" | "security" | "newpackage";
interface ListAdvisory {
advisoryid: { t: "i", v: number };
name: { t: "s", v: string };
description: { t: "s", v: string };
status: { t: "s", v: string };
severity: { t: "s", v: Severity };
type: { t: "s", v: AdvisoryType };
collections: { t: "aa{sv}", v: [ { packages: { t: "aa{sv}", v: CollectionPackage[] } } ] }
references: { t: "a(ssss)", v: [string, string, string, string] }
}
interface ResolvePackage {
arch: { t: "s"; v: string };
download_size: { t: "t"; v: number };
epoch: { t: "s"; v: string };
evr: { t: "s"; v: string };
from_repo_id: { t: "s"; v: string };
id: { t: "i"; v: number };
install_size: { t: "t"; v: number };
name: { t: "s"; v: string };
reason: { t: "s"; v: string };
release: { t: "s"; v: string };
repo_id: { t: "s"; v: string };
version: { t: "s"; v: string };
}
interface TransactionProblem {
action: { t: "u", v: number };
additional_data: { t: "as", "v": string[] };
goal_job_settings: { t: "a{vs}", "v": { to_repo_ids: { t: "as", v: string[] } } };
problem: { t: "u", v: number };
spec: { t: "s", v: "appstream-data" };
}
interface RepoListResult {
cache_updated: { t: "x", "v": number };
}
interface HistoryListPackage {
arch: { t: "s", v: string }
evr: { t: "s", v: string }
name: { t: "s", v: string }
}
interface HistoryListResult {
id: { t: "x", "v": number };
start: { t: "x", "v": number };
end: { t: "x", "v": number };
user_id: { t: "u", "v": number };
status: { t: "s", "v": string };
description: { t: "s", "v": string };
downgraded: { t: "aa{sv}", "v": HistoryListPackage[] };
installed: { t: "aa{sv}", "v": HistoryListPackage[] };
reinstalled: { t: "aa{sv}", "v": HistoryListPackage[] };
removed: { t: "aa{sv}", "v": HistoryListPackage[] };
upgraded: { t: "aa{sv}", "v": HistoryListPackage[] };
}
enum GoalProblem {
ALREADY_INSTALLED = (1 << 12)
}
type object_type = "Package" | "Group" | "Environment" | "Module" | "Skipped";
type action = "Install" | "Upgrade" | "Downgrade" | "Reinstall" | "Remove" | "Replaced" | "Reset" | "Enable" | "Disable" | "Reason Change" | "Switch"
type reason = "User" | "Dependency" | "Clean" | "Group" | "None" | "Weak Dependency" | "External User"
type TransactionItem = [
object_type,
action,
reason,
unknown,
ResolvePackage
]
type InstallResolveResult = [TransactionItem[], number]
type RemoveResolveResult = [TransactionItem[], number]
type UpgradeResolveResult = [TransactionItem[], number]
type SignalCB = (_path: string, _iface: string, signal: string, args: unknown[]) => void
* Call a dnf5daemon method
*/
function call(objectPath: string, iface: string, method: string, args?: unknown[], opts?: cockpit.DBusCallOptions) {
return dbus_client().call(objectPath, iface, method, args, opts);
}
export class Dnf5DaemonManager implements PackageManager {
name: string;
constructor() {
this.name = 'dnf5daemon';
}
private async with_session(executor: (session: string) => Promise<void>, signal_handler?: SignalCB): Promise<void> {
let session: string | null = null;
let subscription: { remove: () => void } | null = null;
const client = dbus_client();
if (signal_handler)
subscription = client.subscribe({}, signal_handler);
try {
session = await open_session();
await executor(session);
} finally {
if (session)
await close_session(session);
if (subscription)
subscription.remove();
}
}
async check_missing_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<MissingPackages> {
const data: MissingPackages = {
extra_names: [],
missing_ids: [],
missing_names: [],
unavailable_names: [],
remove_names: [],
download_size: 0,
};
if (pkgnames.length === 0)
return data;
async function resolve(session: string) {
const installed_names = new Set();
const seen_names = new Set();
const package_attrs = ["name", "is_installed"];
const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [
{
package_attrs: { t: 'as', v: package_attrs },
scope: { t: 's', v: "all" },
patterns: { t: 'as', v: pkgnames }
}
]) as ListPackage[][];
for (const pkg of results) {
if (seen_names.has(pkg.name.v))
continue;
if (pkg.is_installed.v) {
installed_names.add(pkg.name.v);
} else {
data.missing_ids.push(pkg.name.v);
data.missing_names.push(pkg.name.v);
}
seen_names.add(pkg.name.v);
}
pkgnames.forEach((name: string) => {
if (!installed_names.has(name) && data.missing_names.indexOf(name) === -1)
data.unavailable_names.push(name);
});
}
async function simulate(session: string) {
if (data.missing_ids.length === 0 || data.unavailable_names.length > 0) {
return null;
}
await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [pkgnames, {}]);
const [transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as InstallResolveResult;
if (result !== 0) {
const [problems] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems", []) as TransactionProblem[][];
if (problems.every((p: TransactionProblem) => p.problem.v === GoalProblem.ALREADY_INSTALLED)) {
await call(session, "org.rpm.dnf.v0.Goal", "reset", []);
} else {
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving install failed with result=${result}. ${problem}`);
}
}
for (const transaction_item of transaction_items) {
const [object_type, action, reason, _transaction_item_attributes, pkg] = transaction_item;
const name = pkg.name.v;
if (object_type !== "Package") {
console.error(`Simulated install for ${pkgnames} resolved an unexpected object_type=${object_type}`);
continue;
}
data.download_size += pkg.download_size.v;
if (reason === "Dependency") {
if (data.missing_names.indexOf(name) === -1)
data.extra_names.push(name);
}
if (action === "Replaced") {
if (data.remove_names.indexOf(name) === -1)
data.remove_names.push(name);
}
}
await call(session, "org.rpm.dnf.v0.Goal", "reset", []);
}
function signal_emitted(_path: string, _iface: string, _signal: string, _args: unknown[]) {
if (progress_cb) {
progress_cb({
waiting: false,
percentage: 0,
cancel: null,
});
}
}
await this.refresh(false);
await this.with_session(async (session) => {
try {
await resolve(session);
await simulate(session);
} catch (err) {
console.warn("check_missing_packages", err);
throw err;
}
}, signal_emitted);
return data;
}
async install_missing_packages(data: MissingPackages, progress_cb?: InstallProgressCB): Promise<void> {
if (!data || data.missing_ids.length === 0)
return;
let last_info: number;
let last_progress = 0;
let last_name: string;
let total_packages: number;
function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) {
switch (signal) {
case 'download_add_new': {
last_info = InstallProgressType.DOWNLOADING;
last_name = args[2] as string;
break;
}
case 'download_progress': {
last_info = InstallProgressType.DOWNLOADING;
break;
}
case 'download_end':
last_info = 0;
last_name = "";
break;
case 'transaction_before_begin':
[, total_packages] = args as [string, number];
last_info = InstallProgressType.INSTALLING;
break;
case 'transaction_elem_progress': {
let processed = 0;
[, last_name, processed,] = args as [string, string, number, number];
last_progress = processed / total_packages * 100;
break;
}
}
if (progress_cb)
progress_cb({
cancel: null,
info: last_info,
package: last_name,
percentage: last_progress,
waiting: false,
});
}
await this.with_session(async (session) => {
try {
await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [data.missing_names, {}]);
const [, resolve_result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]);
if (resolve_result !== 0) {
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving install failed with result=${resolve_result} ${problem}`);
}
await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
} catch (err) {
console.warn("install error", err);
}
}, signal_emitted);
}
async refresh(_force: boolean, _progress_cb?: ProgressCB): Promise<void> {
await this.with_session(async (session) => {
await call(session, "org.rpm.dnf.v0.Base", "read_all_repos", []);
const [, resolve_result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as [unknown[], number];
if (resolve_result !== 0) {
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving read_all_repos failed with result=${resolve_result} - ${problem}`);
}
await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
});
}
async is_installed(pkgnames: string[]): Promise<boolean> {
const uninstalled = new Set(pkgnames);
await this.with_session(async (session) => {
const package_attrs = ["name", "is_installed"];
const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [
{
package_attrs: { t: 'as', v: package_attrs },
scope: { t: 's', v: "all" },
patterns: { t: 'as', v: pkgnames }
}
]) as ListPackage[][];
for (const pkg of results) {
if (pkg.is_installed.v) {
uninstalled.delete(pkg.name.v);
}
}
});
return uninstalled.size === 0;
}
async install_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<void> {
let last_progress = 0;
let total_packages: number;
function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) {
switch (signal) {
case 'transaction_before_begin':
[, total_packages] = args as [string, number];
break;
case 'transaction_elem_progress': {
const [, _last_name, processed,] = args as [string, string, number, number];
last_progress = processed / total_packages * 100;
break;
}
}
if (progress_cb) {
progress_cb({
waiting: false,
percentage: last_progress,
cancel: null,
});
}
}
await this.with_session(async (session) => {
await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [pkgnames, {}]);
const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as InstallResolveResult;
if (result !== 0) {
const [problems] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems", []) as TransactionProblem[][];
if (problems.every((p: TransactionProblem) => p.problem.v === GoalProblem.ALREADY_INSTALLED)) {
await call(session, "org.rpm.dnf.v0.Goal", "reset", []);
return;
}
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving install failed with result=${result}. ${problem}`);
}
await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
}, signal_emitted);
}
async remove_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<void> {
let last_progress = 0;
let total_packages: number;
function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) {
switch (signal) {
case 'transaction_before_begin':
[, total_packages] = args as [string, number];
break;
case 'transaction_elem_progress': {
const [, _last_name, processed,] = args as [string, string, number, number];
last_progress = processed / total_packages * 100;
break;
}
}
if (progress_cb) {
progress_cb({
waiting: false,
percentage: last_progress,
cancel: null,
});
}
}
await this.with_session(async (session) => {
await call(session, "org.rpm.dnf.v0.rpm.Rpm", "remove", [pkgnames, {}]);
const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as RemoveResolveResult;
if (result !== 0) {
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving remove failed with result=${result}. ${problem}`);
}
await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
}, signal_emitted);
}
async find_file_packages(files: string[], progress_cb?: ProgressCB): Promise<string[]> {
const installed: string[] = [];
await this.with_session(async (session) => {
const package_attrs = ["name"];
const [results] = 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: files },
with_filenames: { t: 'b', v: true },
}
]) as ListPackage[][];
for (const result of results) {
installed.push(result.name.v);
}
if (progress_cb)
progress_cb({ percentage: 100, waiting: false, cancel: null });
});
return installed;
}
async get_updates<T extends boolean>(detail: T, _progress_cb?: ProgressCB): Promise<T extends true ? UpdateDetail[] : Update[]> {
const update_map = new Map<string, Update | UpdateDetail>();
const package_attrs = ["name", "version", "arch", "epoch", "nevra"];
await this.with_session(async (session) => {
const pkgnames = [];
const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [
{
package_attrs: { t: 'as', v: package_attrs },
scope: { t: 's', v: "upgrades" },
}
]) as ListPackage[][];
for (const result of results) {
cockpit.assert(result.nevra, "nevra not set");
pkgnames.push(result.name.v);
update_map.set(result.nevra.v, {
id: result.name.v,
name: result.name.v,
arch: result.arch.v,
version: result.version.v,
});
}
if (detail) {
const advisory_attrs = ["advisoryid", "name", "title", "type", "severity", "description", "references", "collections", "message"];
const [advisories] = await call(session, "org.rpm.dnf.v0.Advisory", "list", [
{
advisory_attrs: { t: 'as', v: advisory_attrs },
availability: { t: 's', v: "upgrades" },
contains_pkgs: { t: 'as', v: pkgnames },
}
]) as ListAdvisory[][];
for (const advisory of advisories) {
for (const collection of advisory.collections.v) {
for (const pkg of collection.packages.v) {
let update = update_map.get(pkg.nevra.v);
if (!update)
continue;
const bug_urls: string[] = [];
const cve_urls: string[] = [];
const vendor_urls: string[] = [];
for (const [, type, _title, url] of advisory.references.v) {
switch (type) {
case "bugzilla":
bug_urls.push(url);
break;
case "cve":
cve_urls.push(url);
break;
case "vendor":
vendor_urls.push(url);
break;
}
}
let severity = Severity.LOW;
switch (advisory.type.v) {
case "bugfix":
severity = Severity.IMPORTANT;
break;
case "enhancement":
severity = Severity.MODERATE;
break;
case "security":
severity = Severity.CRITICAL;
break;
}
update = {
...update,
description: advisory.description.v,
severity,
markdown: false,
bug_urls,
cve_urls,
vendor_urls,
};
update_map.set(pkg.nevra.v, update);
break;
}
}
}
}
});
return Array.from(update_map.values()) as T extends true ? UpdateDetail[] : Update[];
}
async update_packages(updates: Update[] | UpdateDetail[], handlers: UpdateProgressHandlers): Promise<TransactionExitStatus> {
const pkgnames = updates.map(update => update.id);
let last_progress = 0;
let total_packages: number;
function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) {
switch (signal) {
case 'transaction_before_begin':
[, total_packages] = args as [string, number];
break;
case 'transaction_elem_progress': {
const [, _last_name, processed,] = args as [string, string, number, number];
last_progress = processed / total_packages * 100;
break;
}
}
handlers.on_notify({
percentage: last_progress,
cancel: null,
});
}
await this.with_session(async (session) => {
await call(session, "org.rpm.dnf.v0.rpm.Rpm", "upgrade", [pkgnames, {}]);
const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as UpgradeResolveResult;
if (result !== 0) {
const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []);
throw new ResolveError(`Resolving upgrade failed with result=${result}. ${problem}`);
}
await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]);
}, signal_emitted);
return TransactionExitStatus.SUCCESS;
}
async get_running_update(_handlers: UpdateProgressHandlers): Promise<RunningUpdate | null> {
return null;
}
async get_backend(): Promise<string> {
return "dnf5";
}
async get_last_refresh_time(): Promise<number> {
let last_time = 0;
await this.with_session(async (session) => {
const [results] = await call(session, "org.rpm.dnf.v0.rpm.Repo", "list", [{ repo_attrs: { t: 'as', v: ['cache_updated'] } }]) as RepoListResult[][];
for (const result of results) {
if (result.cache_updated.v > last_time)
last_time = result.cache_updated.v;
}
});
const now = parseInt(await cockpit.spawn(["date", "+%s"]), 10);
return now - last_time;
}
async get_history(): Promise<History[]> {
const history: History[] = [];
await this.with_session(async (session) => {
const [results] = await call(session, "org.rpm.dnf.v0.History", "list", [{ limit: { t: 'x', v: 50 } }]) as HistoryListResult[][];
for (const result of results) {
if (result.upgraded.v.length === 0)
continue;
const timestamp = result.end.v;
const pkgs = { timestamp, packages: {} } as History;
for (const pkg of result.upgraded.v) {
pkgs.packages[pkg.name.v] = pkg.evr.v;
}
history.push(pkgs);
}
});
return history;
}
async is_available(pkgnames: string[]): Promise<boolean> {
const available = new Set<string>();
await this.with_session(async (session) => {
const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [
{
package_attrs: { t: 'as', v: ["name"] },
scope: { t: 's', v: "available" },
patterns: { t: 'as', v: pkgnames }
}
]) as ListPackage[][];
for (const pkg of results) {
available.add(pkg.name.v);
}
});
return available.size === new Set(pkgnames).size;
}
}