* Copyright (C) 2025 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import { InstallProgressCB, MissingPackages, PackageManager, ProgressCB, InstallProgressType, UpdateDetail, Update, ProgressData, History } from './packagemanager-abstract';
import * as PK from "packagekit.js";
const InstallProgressMap = {
[PK.Enum.INFO_DOWNLOADING]: InstallProgressType.DOWNLOADING,
[PK.Enum.INFO_UPDATING]: InstallProgressType.UPDATING,
[PK.Enum.INFO_REMOVING]: InstallProgressType.REMOVING,
[PK.Enum.INFO_INSTALLING]: InstallProgressType.INSTALLING,
[PK.Enum.INFO_REINSTALLING]: InstallProgressType.REINSTALLING,
[PK.Enum.INFO_DOWNGRADING]: InstallProgressType.DOWNGRADING,
};
export class PackageKitManager implements PackageManager {
name: string;
constructor() {
this.name = "packagekit";
}
*
* First call check_missing_packages to determine whether something
* needs to be installed, then call install_missing_packages to
* actually install them.
*
* check_missing_packages resolves to an object that can be passed to
* install_missing_packages. It contains these fields:
*
* - missing_names: Packages that were requested, are currently not installed,
* and can be installed.
*
* - missing_ids: The full PackageKit IDs corresponding to missing_names
*
* - unavailable_names: Packages that were requested, are currently not installed,
* but can't be found in any repository.
*
* If unavailable_names is empty, a simulated installation of the missing packages
* is done and the result also contains these fields:
*
* - extra_names: Packages that need to be installed as dependencies of
* missing_names.
*
* - remove_names: Packages that need to be removed.
*
* - download_size: Bytes that need to be downloaded.
*/
async check_missing_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<MissingPackages> {
const install_ids: string[] = [];
const installed_names = new Set();
const data: MissingPackages = {
download_size: 0,
missing_ids: [],
missing_names: [],
unavailable_names: [],
extra_names: [],
remove_names: [],
};
if (pkgnames.length === 0)
return data;
await this.refresh(false, progress_cb);
await PK.cancellableTransaction("Resolve",
[PK.Enum.FILTER_ARCH | PK.Enum.FILTER_NOT_SOURCE | PK.Enum.FILTER_NEWEST, pkgnames],
progress_cb,
{
Package: (_info: number, package_id: string) => {
const parts = package_id.split(";");
const repos = parts[3].split(":");
if (repos.indexOf("installed") >= 0) {
installed_names.add(parts[0]);
} else {
data.missing_ids.push(package_id);
data.missing_names.push(parts[0]);
}
},
});
pkgnames.forEach(name => {
if (!installed_names.has(name) && data.missing_names.indexOf(name) == -1)
data.unavailable_names.push(name);
});
if (data.missing_ids.length > 0 && data.unavailable_names.length === 0) {
await PK.cancellableTransaction("InstallPackages",
[PK.Enum.TRANSACTION_FLAG_SIMULATE, data.missing_ids],
progress_cb,
{
Package: (info: number, package_id: string) => {
const name = package_id.split(";")[0];
if (info == PK.Enum.INFO_REMOVING) {
data.remove_names.push(name);
} else if (info == PK.Enum.INFO_INSTALLING ||
info == PK.Enum.INFO_UPDATING) {
install_ids.push(package_id);
if (data.missing_names.indexOf(name) == -1)
data.extra_names.push(name);
}
}
});
data.missing_names.sort();
data.extra_names.sort();
data.remove_names.sort();
}
if (install_ids.length > 0) {
await PK.cancellableTransaction("GetDetails",
[install_ids],
progress_cb,
{
Details: (details: { size: { v: number, t: string } }) => {
if (details.size)
data.download_size += details.size.v;
}
});
}
return data;
}
*
* In addition to the usual "waiting", "percentage", and "cancel"
* fields, the object reported by progress_cb also includes "info" and
* "package" from the "Package" signal.
*/
async install_missing_packages(data: MissingPackages, progress_cb?: InstallProgressCB): Promise<void> {
if (!data || data.missing_ids.length === 0)
return;
let last_progress: ProgressData | null = null;
let last_info = 0;
let last_name = "";
function report_progess() {
if (progress_cb && last_progress !== null)
progress_cb({
waiting: last_progress.waiting,
percentage: last_progress.percentage,
cancel: last_progress.cancel,
info: InstallProgressMap[last_info],
package: last_name
});
}
await PK.cancellableTransaction("InstallPackages", [0, data.missing_ids],
(p: ProgressData) => {
last_progress = p;
report_progess();
},
{
Package: (info: number, id: string) => {
last_info = info;
last_name = id.split(";")[0];
report_progess();
}
});
}
async refresh(force: boolean, progress_cb?: ProgressCB): Promise<void> {
return PK.refresh(force, progress_cb);
}
async is_installed(pkgnames: string[]): Promise<boolean> {
const uninstalled = new Set(pkgnames);
if (uninstalled.size === 0)
return true;
await PK.cancellableTransaction("Resolve",
[PK.Enum.FILTER_ARCH | PK.Enum.FILTER_NOT_SOURCE | PK.Enum.FILTER_INSTALLED, pkgnames],
null,
{
Package: (_info: unknown, package_id: string) => {
const parts = package_id.split(";");
uninstalled.delete(parts[0]);
},
});
return uninstalled.size === 0;
}
async install_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<void> {
const flags = PK.Enum.FILTER_ARCH | PK.Enum.FILTER_NOT_SOURCE | PK.Enum.FILTER_NEWEST;
const ids: string[] = [];
await PK.cancellableTransaction("Resolve", [flags | PK.Enum.FILTER_NOT_INSTALLED, Array.from(pkgnames)], null,
{
Package: (_info: unknown, package_id: string) => ids.push(package_id),
});
if (ids.length === 0)
return Promise.reject(new PK.TransactionError("not-found", "Can't resolve package(s)"));
else
return PK.cancellableTransaction("InstallPackages", [0, ids], progress_cb)
.catch(ex => {
if (ex.code != PK.Enum.ERROR_ALREADY_INSTALLED)
return Promise.reject(ex);
});
}
async remove_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise<void> {
const ids: string[] = [];
await PK.cancellableTransaction("Resolve", [PK.Enum.FILTER_NOT_SOURCE | PK.Enum.FILTER_INSTALLED | PK.Enum.FILTER_NOT_SOURCE, pkgnames], null,
{
Package: (_info: unknown, package_id: string) => ids.push(package_id),
});
if (ids.length === 0)
return Promise.resolve();
return PK.cancellableTransaction("RemovePackages", [0, ids, true, false], progress_cb);
}
async find_file_packages(files: string[], progress_cb?: ProgressCB): Promise<string[]> {
const installed: string[] = [];
await PK.cancellableTransaction("SearchFiles",
[PK.Enum.FILTER_ARCH | PK.Enum.FILTER_NOT_SOURCE | PK.Enum.FILTER_INSTALLED, files],
progress_cb,
{
Package: (_info: unknown, package_id: string) => {
const pkg = package_id.split(";")[0];
installed.push(pkg);
},
});
return installed;
}
async get_updates<T extends boolean>(detail: T, progress_cb?: ProgressCB): Promise<T extends true ? UpdateDetail[] : Update[]> {
const updates = await PK.get_updates(detail, progress_cb);
return updates as unknown as T extends true ? UpdateDetail[] : Update[];
}
async update_packages(updates: Update[] | UpdateDetail[], progress_cb?: ProgressCB, transaction_path?: string): Promise<void> {
return PK.update_packages(updates, progress_cb, transaction_path);
}
async get_backend(): Promise<string> {
const [result] = await PK.call("/org/freedesktop/PackageKit",
"org.freedesktop.DBus.Properties",
"Get", ["org.freedesktop.PackageKit", "BackendName"]) as [{ v: string, t: string }];
return result.v;
}
async get_last_refresh_time(): Promise<number> {
const [seconds] = await PK.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTimeSinceAction", [PK.Enum.ROLE_REFRESH_CACHE]);
return seconds;
}
async get_history(): Promise<History[]> {
const history = [] as History[];
await PK.transaction("GetOldTransactions", [0], {
Transaction: (_objPath: string, timeSpec: string, _succeeded: string, role: number, _duration: string, data: string) => {
if (role !== PK.Enum.ROLE_UPDATE_PACKAGES)
return;
* data looks like:
* downloading\tbash-completion;1:2.6-1.fc26;noarch;updates-testing
* updating\tbash-completion;1:2.6-1.fc26;noarch;updates-testing
* timeSpec will be one of:
* 2026-01-29T12:57:49.112827-08
* 2026-01-29T19:27:49.112827-01:30
* 2026-01-29T20:57:49.112827Z
* depending on timezone and PackageKit version
*/
let timestamp = Date.parse(timeSpec);
if (isNaN(timestamp)) {
* Neither Firefox's nor Chromium's parsers handle the short offset
* format (first one) as of 2026-01:
* https://bugzilla.mozilla.org/show_bug.cgi?id=2013444
* https://issues.chromium.org/issues/479862357
* so we hack around it by adding the :00
*/
const shortoff_regex = /[-+]\d\d$/;
if (shortoff_regex.exec(timeSpec)) {
const fixed_timeSpec = timeSpec + ":00";
timestamp = Date.parse(fixed_timeSpec);
}
}
if (isNaN(timestamp)) {
console.warn(`Transaction has an invalid timeSpec=${timeSpec}, skipping`);
return;
}
const pkgs = { timestamp, packages: {} } as History;
let empty = true;
data.split("\n").forEach(line => {
const fields = line.trim().split("\t");
if (fields.length >= 2) {
const pkgId = fields[1].split(";");
pkgs.packages[pkgId[0]] = pkgId[1];
empty = false;
}
});
if (!empty)
history.unshift(pkgs);
},
});
return history;
}
* Check a list of packages whether they are available.
*
* @param {string[]} pkgnames - names of packages which should be available in the repositories
* @return {Promise<boolean>} true if packages are available
*/
async is_available(pkgnames: string[]): Promise<boolean> {
const available = new Set();
if (pkgnames && pkgnames.length === 0)
return true;
await PK.cancellableTransaction("Resolve",
[PK.Enum.FILTER_ARCH | PK.Enum.FILTER_NEWEST | PK.Enum.FILTER_NOT_INSTALLED, pkgnames],
null,
{
Package: (_info: unknown, package_id: string) => {
const pkgname = package_id.split(";")[0];
available.add(pkgname);
},
});
return available.size === new Set(pkgnames).size;
}
}