// SPDX-FileCopyrightText: 2025 Aleksandr Mezin <mezin.alexander@gmail.com>
//
// SPDX-License-Identifier: MIT
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Gi from 'gi';
/**
* Extract the version prefix by removing the last component.
*
* @private
* @param {string} version - Version string.
* @returns {string} Version prefix without the last component, or empty string.
*/
function getVersionPrefix(version) {
const index = version.lastIndexOf('.');
return index === -1 ? '' : version.slice(0, index);
}
/**
* Get list of OS identifiers (from /etc/os-release) for package resolution.
* Generates version-specific IDs (e.g., "debian:12", "debian:11")
* and includes ID_LIKE entries for derivative distributions.
*
* @private
* @returns {string[]} List of OS identifiers.
*/
function getOsIds() {
let osIds = [];
let osId = GLib.get_os_info('ID');
let osVersionId = GLib.get_os_info('VERSION_ID');
if (!osId)
throw new Error('Can not query OS info');
for (let prefix = osVersionId; prefix; prefix = getVersionPrefix(prefix))
osIds.push(`${osId}:${prefix}`);
osIds.push(osId);
const osLike = GLib.get_os_info('ID_LIKE');
if (osLike) {
for (const like of osLike.split(' ')) {
if (like)
osIds.push(like);
}
}
if (osIds.includes('ubuntu') && !osIds.includes('debian'))
osIds.push('debian');
return osIds;
}
/**
* Cached list of OS identifiers for package resolution.
*
* @private
* @type {string[]|undefined}
*/
let cachedOsIds;
/**
* Get cached list of OS identifiers for package resolution.
*
* @private
* @returns {string[]} Cached list of OS identifiers.
*/
function getOsIdsCached() {
cachedOsIds ??= getOsIds();
return cachedOsIds;
}
/**
* Information about typelib dependency - object with typelib file name
* and an optional list of OS packages to install.
*
* @typedef TypelibInfo
* @property {string} filename - File name of the library.
* @property {string[]|null} [packages] - List of OS packages that need to be
* installed to use this library.
*/
/**
* Function that resolves typelib package name(s) for the current distro.
*
* @callback TypelibResolver
* @returns {TypelibInfo} Typelib file name and optional package list.
*/
/**
* Resolve a typelib file name to package information based on OS ID.
* Iterates through cached OS IDs and returns the first matching distro entry.
*
* @private
* @param {string} filename - The typelib filename to resolve.
* @param {Partial<Record<string,string[]|null>>} distros - Mapping of OS IDs to package lists.
* @returns {TypelibInfo} Typelib information with filename and optional packages.
*/
function resolveByOsId(filename, distros) {
for (const osId of getOsIdsCached()) {
const packages = distros[osId];
if (packages !== undefined)
return {packages, filename};
}
return {filename};
}
/**
* Package definitions for GObject introspection typelibs.
* Organized by namespace and version, each entry maps to a resolver function
* that returns the appropriate package names for the current OS.
*
* @type {Partial<Record<string, Partial<Record<string, TypelibResolver>>>>}
*/
export const packages = {
Adw: {
'1': () => resolveByOsId('Adw-1.typelib', {
alpine: ['libadwaita'],
arch: ['libadwaita'],
debian: ['gir1.2-adw-1'],
fedora: ['libadwaita'],
suse: ['typelib-1_0-Adw-1'],
}),
},
Gdk: {
'3.0': () => resolveByOsId('Gdk-3.0.typelib', {
alpine: ['gtk+3.0'],
arch: ['gtk3'],
debian: ['gir1.2-gtk-3.0'],
fedora: ['gtk3'],
suse: ['typelib-1_0-Gtk-3_0'],
}),
'4.0': () => resolveByOsId('Gdk-4.0.typelib', {
alpine: ['gtk4.0'],
arch: ['gtk4'],
debian: ['gir1.2-gtk-4.0'],
fedora: ['gtk4'],
suse: ['typelib-1_0-Gtk-4_0'],
}),
},
Gtk: {
'3.0': () => resolveByOsId('Gtk-3.0.typelib', {
alpine: ['gtk+3.0'],
arch: ['gtk3'],
debian: ['gir1.2-gtk-3.0'],
fedora: ['gtk3'],
suse: ['typelib-1_0-Gtk-3_0'],
}),
'4.0': () => resolveByOsId('Gtk-4.0.typelib', {
alpine: ['gtk4.0'],
arch: ['gtk4'],
debian: ['gir1.2-gtk-4.0'],
fedora: ['gtk4'],
suse: ['typelib-1_0-Gtk-4_0'],
}),
},
Handy: {
'1': () => resolveByOsId('Handy-1.typelib', {
alpine: ['libhandy1'],
arch: ['libhandy'],
debian: ['gir1.2-handy-1'],
fedora: ['libhandy'],
suse: ['typelib-1_0-Handy-1_0'],
}),
},
cairo: {
'1.0': () => resolveByOsId('cairo-1.0.typelib', {
alpine: ['gobject-introspection'],
arch: ['gobject-introspection-runtime'],
debian: ['gir1.2-freedesktop'],
fedora: ['gobject-introspection'],
suse: ['girepository-1_0'],
}),
},
Pango: {
'1.0': () => resolveByOsId('Pango-1.0.typelib', {
alpine: ['pango'],
arch: ['pango'],
debian: ['gir1.2-pango-1.0'],
fedora: ['pango'],
suse: ['typelib-1_0-Pango-1_0'],
}),
},
Vte: {
'2.91': () => resolveByOsId('Vte-2.91.typelib', {
alpine: ['vte3'],
arch: ['vte3'],
debian: ['gir1.2-vte-2.91'],
fedora: ['vte291'],
suse: ['typelib-1_0-Vte-2_91'],
}),
'3.91': () => resolveByOsId('Vte-3.91.typelib', {
alpine: ['vte3-gtk4'],
// https://gitlab.alpinelinux.org/alpine/aports/-/issues/17029
'alpine:3.20': ['vte3', 'vte3-gtk4'],
'alpine:3.21': ['vte3', 'vte3-gtk4'],
arch: ['vte4'],
debian: ['gir1.2-vte-3.91'],
fedora: ['vte291-gtk4'],
suse: ['typelib-1_0-Vte-3_91'],
}),
},
GioUnix: {
'2.0': () => resolveByOsId('GioUnix-2.0.typelib', {
alpine: ['glib'],
arch: ['glib2'],
debian: ['gir1.2-glib-2.0'],
fedora: ['glib2'],
'opensuse-leap:15': null,
suse: ['typelib-1_0-Gio-2_0'],
}),
},
GLibUnix: {
'2.0': () => resolveByOsId('GLibUnix-2.0.typelib', {
alpine: ['glib'],
arch: ['glib2'],
debian: ['gir1.2-glib-2.0'],
fedora: ['glib2'],
'opensuse-leap:15': null,
suse: ['typelib-1_0-GLibUnix-2_0'],
}),
},
Template: {
'1.0': () => resolveByOsId('Template-1.0.typelib', {
alpine: ['template-glib'],
arch: ['template-glib'],
debian: ['gir1.2-template-1.0'],
fedora: ['template-glib'],
rhel: null,
suse: ['typelib-1_0-Template-1_0'],
}),
},
};
/**
* Error thrown when GObject typelibs are missing.
* This error is thrown by {@link require} when one or more typelibs cannot be
* loaded and their corresponding packages or files are identified as missing.
*/
export class MissingDependencies extends Error {
/**
* The set of missing package names.
*
* @type {Set<string>}
*/
packages;
/**
* The set of missing typelib filenames.
*
* @type {Set<string>}
*/
files;
/**
* Create a MissingDependencies error.
*
* @param {Set<string>} pkgs - Missing package names.
* @param {Set<string>} files - Missing typelib filenames.
*/
constructor(pkgs, files) {
const msgParts = [];
if (pkgs.size > 0)
msgParts.push(`Missing packages: ${Array.from(pkgs).join(', ')}.`);
if (files.size > 0)
msgParts.push(`Missing files: ${Array.from(files).join(', ')}.`);
super(msgParts.join(' '));
this.name = 'MissingDependencies';
this.packages = pkgs;
this.files = files;
}
};
/* eslint-disable jsdoc/reject-any-type */
/**
* Import multiple GObject libraries and return the imported modules.
*
* @param {Partial<Record<string, string>>} versions - An object with
* namespaces as keys and verions as values.
* @returns {Partial<Record<string, any>>} Imported modules.
* @throws {MissingDependencies} If a known library is not installed.
* @throws {Error} If unknown library is requested.
*/
export function require(versions) {
/** @type {Partial<Record<string, any>>} */
const found = {};
/** @type {Set<string>} */
const missingPackages = new Set();
/** @type {Set<string>} */
const missingFiles = new Set();
for (const [namespace, version] of Object.entries(versions)) {
if (typeof version !== 'string')
throw new Error(`Version for namespace ${namespace} is not a string`);
const resolver = packages[namespace]?.[version];
if (!resolver) {
throw new Error([
`No definition for namespace ${namespace}, version ${version} found.`,
'If you use gjs-typelib-installer as Meson subproject,',
'try removing the build directory and restarting the build from scratch',
].join(' '));
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
found[namespace] = Gi.require(namespace, version);
} catch (error) {
if (!(error instanceof Error))
throw error;
if (!error.message.includes(`Requiring ${namespace}, version ${version}:`))
throw error;
const {packages: pkgs, filename} = resolver();
if (pkgs) {
for (const pkg of pkgs)
missingPackages.add(pkg);
} else {
missingFiles.add(filename);
}
}
}
if (missingPackages.size > 0 || missingFiles.size > 0)
throw new MissingDependencies(missingPackages, missingFiles);
return found;
}
/* eslint-enable jsdoc/reject-any-type */
/**
* Quote and join command line arguments for shell execution.
*
* @private
* @param {Iterable<string>|Array<string>} argv - Command line arguments.
* @returns {string} Shell-quoted and joined command string.
*/
function shellJoin(argv) {
if (!Array.isArray(argv))
return shellJoin(Array.from(argv));
return argv.map(arg => GLib.shell_quote(arg)).join(' ');
}
/**
* @private
* @param {Gio.Subprocess} subprocess - Subprocess to wait for.
* @param {Gio.Cancellable | null} cancellable - Cancellable object or null.
* @returns {Promise<void>}
*/
function waitCheck(subprocess, cancellable) {
return new Promise((resolve, reject) => {
subprocess.wait_check_async(cancellable, (source, result) => {
try {
Gio.Subprocess.prototype.wait_check_finish.call(source, result);
resolve();
} catch (error) {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error);
}
});
});
}
/**
* @private
* @param {Gio.Subprocess} subprocess - Subprocess to communicate with.
* @param {string | null} stdinBuf - Data to send to stdin or null.
* @param {Gio.Cancellable | null} cancellable - Optional cancellable for aborting the operation.
* @returns {Promise<string[]>} - Stdout and stderr as array: [stdout, stderr].
*/
function communicateUtf8(subprocess, stdinBuf, cancellable) {
return new Promise((resolve, reject) => {
subprocess.communicate_utf8_async(stdinBuf, cancellable, (source, result) => {
try {
const [, stdout, stderr] =
Gio.Subprocess.prototype.communicate_utf8_finish.call(source, result);
resolve([stdout, stderr]);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error);
}
});
});
}
/**
* Spawn a subprocess, wait for it to terminate, and get its stdout as string.
*
* @private
* @param {string[]} argv - Command line.
* @param {Gio.Cancellable|null} cancellable - Optional cancellable for aborting the operation.
* @returns {Promise<string>} Subprocess stdout as string.
*/
async function getSubprocessOutput(argv, cancellable = null) {
cancellable?.set_error_if_cancelled();
const launcher = Gio.SubprocessLauncher.new(Gio.SubprocessFlags.STDOUT_PIPE);
launcher.setenv('LC_ALL', 'C.UTF-8', true);
const subprocess = launcher.spawnv(argv);
try {
const [stdout] = await communicateUtf8(subprocess, null, cancellable);
await waitCheck(subprocess, cancellable);
return stdout;
} finally {
if (subprocess.get_identifier())
subprocess.force_exit();
}
}
/**
* Parse the JSON output from 'pkgcli --json backend' command.
* Extracts and validates the roles array from the JSON response.
*
* @private
* @param {string} stdout - Output of 'pkgcli --json backend'.
* @returns {unknown[]} Roles as array.
*/
function parsePkgCliRoles(stdout) {
const json = /** @type {unknown} */ (JSON.parse(stdout));
if (!json || typeof json !== 'object')
throw new Error(`Expected output to contain a JSON object: ${stdout}`);
if (!('roles' in json))
throw new Error(`Expected output to contain 'roles' key: ${stdout}`);
const {roles} = json;
if (typeof roles === 'string')
return roles.split(';');
if (Array.isArray(roles))
return roles;
throw new Error(`Expected 'roles' to be a string or an array: ${stdout}`);
}
/**
* A function that, given a list of packages, generates the installation command.
*
* @callback InstallCommandResolver
* @param {Iterable<string>} pkgs - List of package names to install.
* @returns {string[]} Command line, as argument list (argv).
*/
/**
* Finds the command to install OS packages using PackageKit.
* Checks for pkgcli and pkcon utilities, returning a function that generates
* the appropriate installation command with cache refresh if supported.
*
* @private
* @param {Gio.Cancellable|null} cancellable - Optional cancellable for
* aborting the operation.
* @returns {Promise<InstallCommandResolver|null>} A function that generates
* the command line, or null if no working PackageKit CLI is found.
*/
async function findPackageKitInstallCommand(cancellable = null) {
cancellable?.set_error_if_cancelled();
const pkgcli = GLib.find_program_in_path('pkgcli');
if (pkgcli) {
const argv = [pkgcli, '--json', 'backend'];
try {
const stdout = await getSubprocessOutput(argv, cancellable);
const roles = parsePkgCliRoles(stdout);
if (roles.includes('install-packages')) {
if (roles.includes('refresh-cache')) {
return pkgs => ['sh', '-c', [
shellJoin([pkgcli, 'refresh', 'force']),
shellJoin(['exec', pkgcli, 'install', ...pkgs]),
].join(' && ')];
} else {
return pkgs => [pkgcli, 'install', ...pkgs];
}
} else {
console.warn(
"%s output doesn't include 'install-packages':",
shellJoin(argv),
stdout
);
}
} catch (ex) {
if (ex instanceof GLib.Error &&
ex.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED))
throw ex;
console.warn("%s doesn't seem to work:", shellJoin(argv), ex);
}
}
const pkcon = GLib.find_program_in_path('pkcon');
if (pkcon) {
const argv = [pkcon, '--plain', 'get-roles'];
try {
const stdout = await getSubprocessOutput(argv, cancellable);
const roles = stdout.split('\n');
if (roles.includes('install-packages')) {
if (roles.includes('refresh-cache')) {
return pkgs => ['sh', '-c', [
shellJoin([pkcon, 'refresh', 'force']),
shellJoin(['exec', pkcon, 'install', ...pkgs]),
].join(' && ')];
} else {
return pkgs => [pkcon, 'install', ...pkgs];
}
} else {
console.warn(
"%s output doesn't include 'install-packages':",
shellJoin(argv),
stdout
);
}
} catch (ex) {
if (ex instanceof GLib.Error &&
ex.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED))
throw ex;
console.warn("%s doesn't seem to work:", shellJoin(argv), ex);
}
}
return null;
}
/**
* Finds the command to install OS packages. Prefers PackageKit pkgcli/pkcon
* when available. Falls back to native package managers with pkexec.
*
* @async
* @param {Gio.Cancellable|null} cancellable - Optional cancellable
* for aborting the operation.
* @returns {Promise<InstallCommandResolver|null>} A function that generates
* the command line, or null if no suitable installation method is found.
*/
export async function findInstallCommand(cancellable = null) {
cancellable?.set_error_if_cancelled();
const packageKit = await findPackageKitInstallCommand(cancellable);
if (packageKit)
return packageKit;
const pkexec = GLib.find_program_in_path('pkexec');
if (!pkexec)
return null;
for (const os of getOsIdsCached()) {
if (os === 'alpine') {
const apk = GLib.find_program_in_path('apk');
if (apk)
return pkgs => [pkexec, apk, '-U', 'add', ...pkgs];
} else if (os === 'arch') {
const pacman = GLib.find_program_in_path('pacman');
if (pacman)
return pkgs => [pkexec, pacman, '-Sy', ...pkgs];
} else if (os === 'debian') {
const apt = GLib.find_program_in_path('apt') ?? GLib.find_program_in_path('apt-get');
if (apt) {
return pkgs => ['sh', '-c', [
shellJoin([pkexec, apt, 'update']),
shellJoin(['exec', pkexec, apt, 'install', ...pkgs]),
].join(' && ')];
}
} else if (os === 'fedora') {
const yum = GLib.find_program_in_path('dnf') ?? GLib.find_program_in_path('yum');
if (yum)
return pkgs => [pkexec, yum, 'install', ...pkgs];
} else if (os === 'suse') {
const zypper = GLib.find_program_in_path('zypper');
if (zypper)
return pkgs => [pkexec, zypper, 'install', ...pkgs];
}
}
return null;
}
/**
* A function that, given a list of arguments, generates the command line
* to run the specified command in a terminal emulator.
*
* @callback TerminalCommandResolver
* @param {Iterable<string>} argv - Command line arguments to run in terminal.
* @returns {string[]} Command line, as argument list (argv).
*/
/**
* Finds a terminal emulator to run commands in.
* Checks for GNOME Console (kgx), gnome-terminal, and xdg-terminal-exec in order.
*
* @async
* @param {Gio.Cancellable|null} cancellable - Optional cancellable for aborting the operation.
* @returns {Promise<TerminalCommandResolver|null>} A function that generates
* the command line, or null if no suitable terminal is found.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export async function findTerminalCommand(cancellable = null) {
cancellable?.set_error_if_cancelled();
const kgx = GLib.find_program_in_path('kgx');
if (kgx)
return argv => [kgx, `--command=${shellJoin(argv)}`];
const gnomeTerminal = GLib.find_program_in_path('gnome-terminal');
if (gnomeTerminal)
return argv => [gnomeTerminal, '--', ...argv];
const xdgTerminalExec = GLib.find_program_in_path('xdg-terminal-exec');
if (xdgTerminalExec)
return argv => [xdgTerminalExec, ...argv];
return null;
}
/**
* Finds the command to install OS packages. Prefers PackageKit CLI when
* available. Wraps the command to launch it in a terminal emulator.
* Combines the results of findTerminalCommand and findInstallCommand to create
* a complete installation command that runs in a terminal.
*
* @async
* @param {Gio.Cancellable|null} cancellable - Optional cancellable for aborting the operation.
* @returns {Promise<InstallCommandResolver|null>} A function that,
* given the list of packages, generates the installation command,
* wrapped for terminal execution, or null if terminal or install command not found.
*/
export async function findTerminalInstallCommand(cancellable = null) {
cancellable?.set_error_if_cancelled();
const terminalCommand = await findTerminalCommand(cancellable);
if (!terminalCommand)
return null;
const installCommand = await findInstallCommand(cancellable);
if (!installCommand)
return null;
return pkgs => terminalCommand(installCommand(pkgs));
}