/* * Copyright (C) 2025 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import { InstallProgressCB, MissingPackages, PackageManager, ProgressCB, InstallProgressType, UpdateProgressType, UpdateDetail, Update, ProgressData, History, UpdateProgressHandlers, TransactionExitStatus, UpdateError, UpdateNotify, RunningUpdate, Severity } from './packagemanager-abstract'; import { superuser } from 'superuser'; const _ = cockpit.gettext; // see https://github.com/PackageKit/PackageKit/blob/main/lib/packagekit-glib2/pk-enum.h export const Enum = { EXIT_SUCCESS: 1, EXIT_FAILED: 2, EXIT_CANCELLED: 3, ROLE_REFRESH_CACHE: 13, ROLE_UPDATE_PACKAGES: 22, INFO_UNKNOWN: -1, INFO_LOW: 3, INFO_ENHANCEMENT: 4, INFO_NORMAL: 5, INFO_BUGFIX: 6, INFO_IMPORTANT: 7, INFO_SECURITY: 8, INFO_DOWNLOADING: 10, INFO_UPDATING: 11, INFO_INSTALLING: 12, INFO_REMOVING: 13, INFO_REINSTALLING: 19, INFO_DOWNGRADING: 20, STATUS_WAIT: 1, STATUS_DOWNLOAD: 8, STATUS_INSTALL: 9, STATUS_UPDATE: 10, STATUS_CLEANUP: 11, STATUS_SIGCHECK: 14, STATUS_WAITING_FOR_LOCK: 30, FILTER_INSTALLED: (1 << 2), FILTER_NOT_INSTALLED: (1 << 3), FILTER_NEWEST: (1 << 16), FILTER_ARCH: (1 << 18), FILTER_NOT_SOURCE: (1 << 21), ERROR_ALREADY_INSTALLED: 9, TRANSACTION_FLAG_SIMULATE: (1 << 2), }; const UpdateProgressMap: Record = { [Enum.STATUS_DOWNLOAD]: UpdateProgressType.DOWNLOADING, [Enum.STATUS_INSTALL]: UpdateProgressType.INSTALLING, [Enum.STATUS_UPDATE]: UpdateProgressType.UPDATING, [Enum.STATUS_CLEANUP]: UpdateProgressType.CLEANUP, [Enum.STATUS_SIGCHECK]: UpdateProgressType.SIGCHECK, }; const InstallProgressMap = { [Enum.INFO_DOWNLOADING]: InstallProgressType.DOWNLOADING, [Enum.INFO_UPDATING]: InstallProgressType.UPDATING, [Enum.INFO_REMOVING]: InstallProgressType.REMOVING, [Enum.INFO_INSTALLING]: InstallProgressType.INSTALLING, [Enum.INFO_REINSTALLING]: InstallProgressType.REINSTALLING, [Enum.INFO_DOWNGRADING]: InstallProgressType.DOWNGRADING, }; function debug(...args: unknown[]) { if (window.debugging === 'all' || window.debugging?.includes('packagekit')) console.debug(...args); } interface SignalHandlers { Package?: (info: number, packageId: string, summary: string) => void; UpdateDetail?: (packageId: string, updates: unknown, obsoletes: unknown, vendor_urls: string[], bug_urls: string[], cve_urls: string[], restart: boolean, update_text: string, changelog: string) => void; ErrorCode?: (code: string, detail: string) => void; Finished?: (exit: number) => void; Details?: (details: { size: { v: number, t: string } }) => void; Transaction?: (objPath: string, timeSpec: string, succeeded: string, role: number, duration: string, data: string) => void; } const transactionInterface = "org.freedesktop.PackageKit.Transaction"; let _dbus_client: cockpit.DBusClient | null = null; /** * Get PackageKit D-Bus client * * This will get lazily initialized and re-initialized after PackageKit * disconnects (due to a crash or idle timeout). */ function dbus_client() { if (_dbus_client === null) { _dbus_client = cockpit.dbus("org.freedesktop.PackageKit", { superuser: "try", track: true }); _dbus_client.addEventListener("close", () => { console.log("PackageKit went away from D-Bus"); _dbus_client = null; }); } return _dbus_client; } // Reconnect when privileges change superuser.addEventListener("changed", () => { _dbus_client = null }); /** * Call a PackageKit method */ export function call(objectPath: string, iface: string, method: string, args?: unknown[] | null, opts?: cockpit.DBusCallOptions) { return dbus_client().call(objectPath, iface, method, args, opts); } /** * Watch a running PackageKit transaction * * transactionPath (string): D-Bus object path of the PackageKit transaction * signalHandlers, notifyHandler: As in method #transaction * Returns: If notifyHandler is set, Cockpit promise that resolves when the watch got set up */ export function watchTransaction(transactionPath: string, signalHandlers: SignalHandlers, notifyHandler?: (props: Record, path: string) => void) { const subscriptions: { remove: () => void }[] = []; let notifyReturn: cockpit.DeferredPromise | undefined; const client = dbus_client(); // Listen for PackageKit crashes while the transaction runs function onClose(_event: unknown, ex: unknown) { console.warn("PackageKit went away during transaction", transactionPath, ":", JSON.stringify(ex)); if (signalHandlers.ErrorCode) signalHandlers.ErrorCode("close", _("PackageKit crashed")); if (signalHandlers.Finished) signalHandlers.Finished(Enum.EXIT_FAILED); } client.addEventListener("close", onClose); if (signalHandlers) { for (const [member, handler] of Object.entries(signalHandlers)) { if (handler) { subscriptions.push( client.subscribe({ interface: transactionInterface, path: transactionPath, member }, (_path, _iface, _signal, args) => handler(...args)) ); } } } if (notifyHandler) { notifyReturn = client.watch(transactionPath); subscriptions.push(notifyReturn as unknown as { remove: () => void }); client.addEventListener("notify", (event: CustomEvent) => { const data = event.detail as Record>; const iface = data?.[transactionPath]?.[transactionInterface]; if (iface) notifyHandler(iface as Record, transactionPath); }); } // unsubscribe when transaction finished subscriptions.push(client.subscribe( { interface: transactionInterface, path: transactionPath, member: "Finished" }, () => { subscriptions.map(s => s.remove()); client.removeEventListener("close", onClose); }) ); return notifyReturn; } /** * Run a PackageKit transaction * * method (string): D-Bus method name on the https://www.freedesktop.org/software/PackageKit/gtk-doc/Transaction.html interface * If undefined, only a transaction will be created without calling a method on it * arglist (array): "in" arguments of @method * signalHandlers (object): maps PackageKit.Transaction signal names to handlers * notifyHandler (function): handler for https://docs.cockpit-project.org/cockpit-guide/latest/guide/cockpit-dbus.html#cockpit-dbus-onnotify * signals, called on property changes with (changed_properties, transaction_path) * Returns: Promise that resolves with transaction path on success, or rejects on an error * * Note that most often you don't really need the transaction path, but want to * listen to the "Finished" signal. * * Example: * transaction("GetUpdates", [0], { * Package: (info, packageId, _summary) => { ... }, * ErrorCode: (code, details) => { ... }, * }, * changedProps => { ... } // notify handler * ) * .then(transactionPath => { ... }) * .catch(ex => { handle exception }); */ export function transaction(method: string, arglist: unknown[], signalHandlers?: SignalHandlers, notifyHandler?: (props: Record, path: string) => void): Promise { return call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "CreateTransaction", []) .then(([transactionPath]) => { if (!signalHandlers && !notifyHandler) return transactionPath; const watchPromise = watchTransaction(transactionPath as string, signalHandlers!, notifyHandler) || Promise.resolve(); return watchPromise.then(() => { if (method) { return call(transactionPath as string, transactionInterface, method, arglist) .then(() => transactionPath); } else { return transactionPath; } }); }); } export class TransactionError extends Error { detail: string; code: string; constructor(code: string, detail: string) { super(detail); this.detail = detail; this.code = code; } } /** * Run a long cancellable PackageKit transaction * * method (string): D-Bus method name on the https://www.freedesktop.org/software/PackageKit/gtk-doc/Transaction.html interface * arglist (array): "in" arguments of @method * progress_cb: Callback that receives a {waiting, percentage, cancel} object regularly; if cancel is not null, it can * be called to cancel the current transaction. if wait is true, PackageKit is waiting for its lock (i. e. * on another package operation) * signalHandlers, notifyHandler: As in method #transaction, but ErrorCode and Finished are handled internally * Returns: Promise that resolves when the transaction finished successfully, or rejects with TransactionError * on failure. */ export function cancellableTransaction(method: string, arglist: unknown[], progress_cb: ProgressCB | null | undefined, signalHandlers?: SignalHandlers): Promise { if (signalHandlers?.ErrorCode || signalHandlers?.Finished) throw Error("cancellableTransaction handles ErrorCode and Finished signals internally"); return new Promise((resolve, reject) => { let cancelled = false; let status: number | undefined; let allow_wait_status = false; const progress_data: ProgressData = { waiting: false, percentage: 0, cancel: null }; function changed(props: Record, transaction_path: string) { function cancel() { call(transaction_path, transactionInterface, "Cancel", []); cancelled = true; } if (progress_cb) { if ("Status" in props) status = props.Status as number; progress_data.waiting = allow_wait_status && (status === Enum.STATUS_WAIT || status === Enum.STATUS_WAITING_FOR_LOCK); if ("AllowCancel" in props) progress_data.cancel = props.AllowCancel ? cancel : null; if ("Percentage" in props && (props.Percentage as number) <= 100) progress_data.percentage = props.Percentage as number; progress_cb(progress_data); } } // We ignore STATUS_WAIT and friends during the first second of a transaction. They // are always reported briefly even when a transaction doesn't really need to wait. window.setTimeout(() => { allow_wait_status = true; changed({}, ""); }, 1000); transaction(method, arglist, Object.assign({ // avoid calling progress_cb after ending the transaction, to avoid flickering cancel buttons ErrorCode: (code: string, detail: string) => { progress_cb = null; reject(new TransactionError(cancelled ? "cancelled" : code, detail)); }, Finished: (_exit: number) => { progress_cb = null; resolve(); }, }, signalHandlers || {}), changed) .catch(ex => { progress_cb = null; reject(ex); }); }); } /** * On Debian the update_text starts with "== version ==" which is * redundant; we don't want Markdown headings in the table * * @param {string} text - update_text to filter */ function removeHeading(text: string) { if (text) return text.trim().replace(/^== .* ==\n/, "") .trim(); return text; } // parse CVEs from an arbitrary text (changelog) and return URL array function parseCVEs(text: string) { if (!text) return []; const cves = text.match(/CVE-\d{4}-\d+/g); if (!cves) return []; return cves.map(n => "https://www.cve.org/CVERecord?id=" + n); } function deduplicate(list: T[]): T[] { return [...new Set(list)].sort(); } function loadUpdateDetailsBatch(pkg_ids: string[], update_details: Record & Update>, progress_cb?: ProgressCB) { return cancellableTransaction("GetUpdateDetail", [pkg_ids], progress_cb, { UpdateDetail: (packageId: string, _updates: unknown, _obsoletes: unknown, vendor_urls: string[], bug_urls: string[], cve_urls: string[], _restart: boolean, update_text: string, changelog: string /* state, issued, updated */) => { const u = update_details[packageId]; if (!u) { console.warn("Mismatching update:", packageId); return; } u.vendor_urls = vendor_urls; u.description = removeHeading(update_text) || changelog; if (update_text) u.markdown = true; u.bug_urls = deduplicate(bug_urls); // many backends don't support proper severities; parse CVEs from description as a fallback u.cve_urls = deduplicate(cve_urls && cve_urls.length > 0 ? cve_urls : parseCVEs(u.description)); if (u.cve_urls && u.cve_urls.length > 0) u.severity = Severity.CRITICAL; u.vendor_urls = vendor_urls || []; // u.restart = restart; // broken (always "1") at least in Fedora debug("UpdateDetail:", u); } }); } export class PackageKitManager implements PackageManager { name: string; constructor() { this.name = "packagekit"; } /* Support for installing missing packages. * * 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 { 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 cancellableTransaction("Resolve", [Enum.FILTER_ARCH | Enum.FILTER_NOT_SOURCE | 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 cancellableTransaction("InstallPackages", [Enum.TRANSACTION_FLAG_SIMULATE, data.missing_ids], progress_cb, { Package: (info: number, package_id: string) => { const name = package_id.split(";")[0]; if (info === Enum.INFO_REMOVING) { data.remove_names.push(name); } else if (info === Enum.INFO_INSTALLING || info === 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 cancellableTransaction("GetDetails", [install_ids], progress_cb, { Details: (details: { size: { v: number, t: string } }) => { if (details.size) data.download_size += details.size.v; } }); } return data; } /* Carry out what check_missing_packages has planned. * * 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 { if (!data || data.missing_ids.length === 0) return; let last_progress: ProgressData | null = null; let last_info = 0; let last_name = ""; function report_progress() { if (progress_cb && last_progress !== null) progress_cb({ waiting: last_progress.waiting, percentage: last_progress.percentage, cancel: last_progress.cancel, info: InstallProgressMap[last_info], // Maps PackageKit state to our own PackageManager state temporary // until all pkg/lib/packagekit use cases are supported by the PackageManager abstraction. package: last_name }); } await cancellableTransaction("InstallPackages", [0, data.missing_ids], (p: ProgressData) => { last_progress = p; report_progress(); }, { Package: (info: number, id: string) => { last_info = info; last_name = id.split(";")[0]; report_progress(); } }); } async refresh(force: boolean, progress_cb?: ProgressCB): Promise { return cancellableTransaction("RefreshCache", [force], progress_cb); } async is_installed(pkgnames: string[]): Promise { const uninstalled = new Set(pkgnames); if (uninstalled.size === 0) return true; await cancellableTransaction("Resolve", [Enum.FILTER_ARCH | Enum.FILTER_NOT_SOURCE | 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 { const flags = Enum.FILTER_ARCH | Enum.FILTER_NOT_SOURCE | Enum.FILTER_NEWEST; const ids: string[] = []; await cancellableTransaction("Resolve", [flags | 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 TransactionError("not-found", "Can't resolve package(s)")); else return cancellableTransaction("InstallPackages", [0, ids], progress_cb) .catch(ex => { if (ex.code !== Enum.ERROR_ALREADY_INSTALLED) return Promise.reject(ex); }); } async remove_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise { const ids: string[] = []; await cancellableTransaction("Resolve", [Enum.FILTER_NOT_SOURCE | Enum.FILTER_INSTALLED | Enum.FILTER_NOT_SOURCE, pkgnames], null, { Package: (_info: unknown, package_id: string) => ids.push(package_id), }); if (ids.length === 0) return Promise.resolve(); return cancellableTransaction("RemovePackages", [0, ids, true, false], progress_cb); } async find_file_packages(files: string[], progress_cb?: ProgressCB): Promise { const installed: string[] = []; await cancellableTransaction("SearchFiles", [Enum.FILTER_ARCH | Enum.FILTER_NOT_SOURCE | Enum.FILTER_INSTALLED, files], progress_cb, { Package: (_info: unknown, package_id: string) => { const pkg = package_id.split(";")[0]; installed.push(pkg); }, }); return installed; } /** * Get Updates * updates = { id, name, version, arch } * with details * updates = { id, name, version, arch, severity, bug_urls, cve_urls, vendor_urls, description, markdown } * @param {boolean} details - fetch detailed package information (security information) */ async get_updates(details: T, progress_cb?: ProgressCB): Promise { const updates: Record & Update> = {}; await cancellableTransaction( "GetUpdates", [0], progress_cb, { Package: (info: number, packageId: string, summary: string) => { // HACK: security updates have 0x50008 with PackageKit 1.2.8, so just consider the lower 8 bits info = info & 0xff; const id_fields = packageId.split(";"); // HACK: dnf backend yields wrong severity with PK < 1.2.4 (https://github.com/PackageKit/PackageKit/issues/268) if (info < Enum.INFO_LOW || info > Enum.INFO_SECURITY) info = Enum.INFO_NORMAL; if (info === Enum.INFO_LOW) info = Severity.LOW; else if (info === Enum.INFO_ENHANCEMENT) info = Severity.MODERATE; else if (info === Enum.INFO_SECURITY) info = Severity.CRITICAL; else if (info >= Enum.INFO_NORMAL) info = Severity.IMPORTANT; else info = Severity.MODERATE; updates[packageId] = { id: packageId, name: id_fields[0], version: id_fields[1], severity: info, arch: id_fields[2], summary }; } }); const pkg_ids = Object.keys(updates); if (details && pkg_ids.length > 0) { const processBatch = async (remaining_ids: string[], current_batch_size: number) => { if (remaining_ids.length === 0) { return; } const batch = remaining_ids.slice(0, current_batch_size); const next_ids = remaining_ids.slice(current_batch_size); try { await loadUpdateDetailsBatch(batch, updates, progress_cb); // continue with next batch using same batch size await processBatch(next_ids, current_batch_size); } catch (ex) { console.warn("GetUpdateDetail failed with batch size", current_batch_size, ":", JSON.stringify(ex)); if (current_batch_size > 1) { // Reduce batch size to 1 and retry console.log("Reducing GetUpdateDetail batch size to 1 and retrying"); await processBatch(remaining_ids, 1); } else { // Even batch size 1 failed, skip this batch and continue console.warn("Failed to load update details for package:", batch[0]); await processBatch(next_ids, 1); } } }; // Avoid exceeding cockpit-ws frame size, so batch the loading of details // if we run into https://issues.redhat.com/browse/RHEL-109779 then we need to fall back to load packages // individually await processBatch(pkg_ids, 500); } return Object.entries(updates).map(([key, u]) => ({ ...u, id: key })) as unknown as T extends true ? UpdateDetail[] : Update[]; } async update_packages(updates: Update[] | UpdateDetail[], handlers: UpdateProgressHandlers): Promise { const update_ids = updates.map(u => u.id); let transactionPath: string | null = null; const errorMessages: string[] = []; return new Promise((resolve, reject) => { transaction("UpdatePackages", [0, update_ids], { ErrorCode: (_code: string, details: string) => errorMessages.push(details), Finished: (exit: number) => { if (exit === Enum.EXIT_SUCCESS) { resolve(TransactionExitStatus.SUCCESS); } else if (exit === Enum.EXIT_CANCELLED) { resolve(TransactionExitStatus.CANCELLED); } else { if (exit !== Enum.EXIT_FAILED) errorMessages.push(cockpit.format(cockpit.gettext("PackageKit reported error code $0"), exit)); reject(new UpdateError(errorMessages)); } }, Package: (status: number, packageId: string) => { if (status in UpdateProgressMap) handlers.on_package(UpdateProgressMap[status], packageId); }, }, (notify: Record, path: string) => { transactionPath = path; const props: UpdateNotify = { cancel: notify.AllowCancel ? () => call(transactionPath!, transactionInterface, "Cancel", []) : null, }; if (notify.Percentage !== undefined) props.percentage = notify.Percentage as number; if (notify.RemainingTime !== undefined) props.remaining_time = notify.RemainingTime as number; if (notify.LastPackage !== undefined) props.last_package = notify.LastPackage as string; handlers.on_notify(props); }, ).catch(reject); }); } async get_running_update(handlers: UpdateProgressHandlers): Promise { let transactions: string[]; let roles: unknown[]; try { [transactions] = await call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTransactionList", []) as [string[]]; roles = await Promise.all(transactions.map(path => call(path, "org.freedesktop.DBus.Properties", "Get", [transactionInterface, "Role"]))); } catch (ex) { console.warn("GetTransactionList: failed to read PackageKit transaction roles:", String(ex)); return null; } for (let idx = 0; idx < roles.length; ++idx) { const [role] = roles[idx] as [{ v: number }]; if (role.v !== Enum.ROLE_UPDATE_PACKAGES) { continue; } const transactionPath = transactions[idx]; const errorMessages: string[] = []; const promise = new Promise((resolve, reject) => { watchTransaction(transactionPath, { ErrorCode: (_code: string, details: string) => errorMessages.push(details), Finished: (exit: number) => { if (exit === Enum.EXIT_SUCCESS) { resolve(TransactionExitStatus.SUCCESS); } else if (exit === Enum.EXIT_CANCELLED) { resolve(TransactionExitStatus.CANCELLED); } else { if (exit !== Enum.EXIT_FAILED) errorMessages.push(cockpit.format(cockpit.gettext("PackageKit reported error code $0"), exit)); reject(new UpdateError(errorMessages)); } }, Package: (status: number, packageId: string) => { if (status in UpdateProgressMap) handlers.on_package(UpdateProgressMap[status], packageId); }, }, (notify: Record) => { const props: UpdateNotify = { cancel: notify.AllowCancel ? () => call(transactionPath, transactionInterface, "Cancel", []) : null, }; if (notify.Percentage !== undefined) props.percentage = notify.Percentage as number; if (notify.RemainingTime !== undefined) props.remaining_time = notify.RemainingTime as number; if (notify.LastPackage !== undefined) props.last_package = notify.LastPackage as string; handlers.on_notify(props); }, ); }); return { promise }; } return null; } async get_backend(): Promise { const [result] = await 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 { const [seconds] = await call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTimeSinceAction", [Enum.ROLE_REFRESH_CACHE]) as [number]; return seconds; } async get_history(): Promise { const history = [] as History[]; // would be nice to filter only for "update-packages" role, but can't here await transaction("GetOldTransactions", [0], { Transaction: (_objPath: string, timeSpec: string, _succeeded: string, role: number, _duration: string, data: string) => { if (role !== 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"; // console.debug(`Transaction has an invalid timeSpec=${timeSpec}, trying fixed=${fixed_timeSpec}`); 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); // PK reports in time-ascending order, but we want the latest first }, }); 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} true if packages are available */ async is_available(pkgnames: string[]): Promise { const available = new Set(); if (pkgnames && pkgnames.length === 0) return true; await cancellableTransaction("Resolve", [Enum.FILTER_ARCH | Enum.FILTER_NEWEST | 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; } }