spiegel-keyman/linux/keyman-config/keyman_config/install_kmp.py
Eberhard Beilharz b7d5e89577
fix(linux): fix installing keyboard in shared area
Installing into the shared area (`/usr/local/share`) requires `root`
permissions, so in order to install into the shared area the user has
to call `km-package-install` with `sudo`. But we also want to add the
keyboard to the user's list of available keyboards so that he can
switch to it. That needs to be done in the user context. However,
when `sudo` ran the command in the user context, the environment
variables to connect to the user's session dbus were not set.

This change fixes this by passing `DBUS_SESSION_BUS_ADDRESS` along
with the `sudo -u <user>` call. Since `root` doesn't have this variable
set (or at least not pointing to the user's dbus), we construct the
dbus address based on the user's uid.

On newer Ubuntu versions (>=25.04) we also have to set `XDG_RUNTIME_DIR`,
at least when running the autopkgtests.

Fixes: #14911
2025-10-14 17:54:36 +02:00

355 lines
15 KiB
Python
Executable file

#!/usr/bin/python3
import json
import logging
import os
import packaging.version
import zipfile
from enum import Enum
from shutil import rmtree
from keyman_config import _, __version__, secure_lookup
from keyman_config.canonical_language_code_utils import CanonicalLanguageCodeUtils
from keyman_config.convertico import checkandsaveico, extractico
from keyman_config.custom_keyboards import CustomKeyboards
from keyman_config.dbus_util import keyboard_list_changed
from keyman_config.fcitx_util import is_fcitx_running
from keyman_config.get_kmp import (InstallLocation, get_keyboard_data,
get_keyboard_dir, get_keyman_doc_dir,
get_keyman_font_dir)
from keyman_config.gnome_keyboards_util import (GnomeKeyboardsUtil,
get_ibus_keyboard_id,
is_gnome_desktop)
from keyman_config.ibus_util import get_ibus_bus, install_to_ibus, restart_ibus
from keyman_config.kmpmetadata import KMFileTypes, get_metadata
from keyman_config.kvk2ldml import convert_kvk_to_ldml, output_ldml
from keyman_config.sentry_handling import SentryErrorHandling
# TODO userdir install
# special processing for kmn if needed
# TODO optionally standardise throughout on variable names
# packageID for kmps and keyboardID for keyboards
# see https://docs.google.com/\
# document/d/1sj7W6pCiN-_iRss5iRdib1aHaSTmYoLIueQSKJeNy8Q/edit#heading=h.mq0rc28mf031
class InstallStatus(Enum):
Continue = 0
Warning = 1
Abort = 2
class InstallError(Exception):
"""Exception raised for errors in KMP installation.
Attributes:
status -- InstallStatus for what to do when the error occurrs
message -- explanation of the error
"""
def __init__(self, status, message):
self.status = status
self.message = message
class InstallKmp():
def __init__(self):
self.packageID = ''
self.packageDir = ''
self.kmpdocdir = ''
self.kmpfontdir = ''
def _check_keyman_dir(self, basedir, error_message):
# check if keyman subdir exists
keyman_dir = os.path.join(basedir, "keyman")
if os.path.isdir(keyman_dir):
# Check for write access of keyman dir to be able to create subdir
if not os.access(keyman_dir, os.X_OK | os.W_OK):
raise InstallError(InstallStatus.Abort, error_message)
elif os.access(basedir, os.X_OK | os.W_OK):
os.mkdir(keyman_dir)
else:
raise InstallError(InstallStatus.Abort, error_message)
def _extract_package_id(self, inputfile):
packageID, ext = os.path.splitext(os.path.basename(inputfile))
return packageID.lower()
def install_kmp_shared(self, inputfile, language=None):
"""
Install a kmp file to /usr/local/share/keyman
Args:
inputfile (str): path to kmp file
"""
self._check_keyman_dir(
'/usr/local/share',
_("You do not have permissions to install the keyboard files to the shared area "
"/usr/local/share/keyman"))
self._check_keyman_dir(
'/usr/local/share/doc',
_("You do not have permissions to install the documentation to the shared "
"documentation area /usr/local/share/doc/keyman"))
self._check_keyman_dir(
'/usr/local/share/fonts',
_("You do not have permissions to install the font files to the shared font area "
"/usr/local/share/fonts"))
return self._install_kmp(inputfile, language, InstallLocation.Shared)
def install_kmp_user(self, inputfile, language=None):
return self._install_kmp(inputfile, language, InstallLocation.User)
def _install_kmp(self, inputfile, language, area):
self.packageID = self._extract_package_id(inputfile)
self.packageDir = get_keyboard_dir(area, self.packageID)
self.kmpdocdir = get_keyman_doc_dir(area, self.packageID)
self.kmpfontdir = get_keyman_font_dir(area, self.packageID)
sentry = SentryErrorHandling()
sentry.add_breadcrumb(category='install', message=f'Installing kmp: {self.packageID} from "{inputfile}" for lang: {language}; area: {"OS" if area == 1 else "Shared" if area == 2 else "User" if area == 3 else "Unknown"}')
if not os.path.isfile(inputfile):
message = _("File {kmpfile} doesn't exist").format(kmpfile=inputfile)
logging.error("install_kmp.py: %s", message)
raise InstallError(InstallStatus.Abort, message)
if not self._safeMakeDirs(self.packageDir):
return
extract_kmp(inputfile, self.packageDir)
info, system, options, keyboards, files = get_metadata(self.packageDir)
self._check_version(inputfile, system)
if not keyboards:
logging.error("install_kmp.py: error: No kmp.json or kmp.inf found in %s", inputfile)
logging.info("Contents of %s:", inputfile)
for o in os.listdir(self.packageDir):
logging.info(o)
rmtree(self.packageDir)
message = _("No kmp.json or kmp.inf found in {packageFile}").format(
packageFile=inputfile)
raise InstallError(InstallStatus.Abort, message)
# sourcery skip: extract-method
logging.info("Installing %s", secure_lookup(info, 'name', 'description'))
process_keyboard_data(self.packageID, self.packageDir)
for kb in keyboards:
if kb['id'] != self.packageID:
process_keyboard_data(kb['id'], self.packageDir)
if files is not None:
self._install_files(keyboards, files)
return self._install_keyboards(keyboards, self.packageDir, language)
def _check_version(self, inputfile, system):
if not system:
return
fileVersion = secure_lookup(system, 'fileVersion')
if not fileVersion:
fileVersion = '7.0'
if packaging.version.parse(fileVersion) > packaging.version.parse(__version__):
logging.error("install_kmp.py: error: %s requires a newer version of Keyman (%s)",
inputfile, fileVersion)
rmtree(self.packageDir)
message = _("{packageFile} requires Keyman {keymanVersion} or higher").format(
packageFile=inputfile, keymanVersion=fileVersion)
raise InstallError(InstallStatus.Abort, message)
def _install_files(self, keyboards, files):
for f in files:
fpath = os.path.join(self.packageDir, f['name'])
ftype = f['type']
if ftype in [KMFileTypes.KM_DOC, KMFileTypes.KM_IMAGE]:
# Special handling of doc and images to hard link them into doc dir
logging.info("Installing %s as documentation", f['name'])
if self._safeMakeDirs(self.kmpdocdir):
kmpdocpath = os.path.join(self.kmpdocdir, f['name'])
self._safeLinkFile(fpath, kmpdocpath)
elif ftype == KMFileTypes.KM_FONT:
# Special handling of font to hard link it into font dir
logging.info("Installing %s as font", f['name'])
if self._safeMakeDirs(self.kmpfontdir):
fontpath = os.path.join(self.kmpfontdir, f['name'])
self._safeLinkFile(fpath, fontpath)
elif ftype == KMFileTypes.KM_OSK:
# Special handling to convert kvk into LDML
logging.info("Converting %s to LDML and installing both as as keyman file",
f['name'])
name, ext = os.path.splitext(f['name'])
ldml = convert_kvk_to_ldml(name, fpath)
if ldml is not None and len(ldml) > 0:
ldmlfile = os.path.join(self.packageDir, f"{name}.ldml")
output_ldml(ldmlfile, ldml)
elif ftype == KMFileTypes.KM_ICON:
# Special handling of icon to convert to PNG
logging.info("Converting %s to PNG and installing both as keyman files",
f['name'])
checkandsaveico(fpath)
elif ftype == KMFileTypes.KM_SOURCE:
# TODO for the moment just leave it for ibus-keyman to ignore if it doesn't load
pass
elif ftype == KMFileTypes.KM_KMX:
# Sanitize keyboard filename if not lower case
kmx_id, ext = os.path.splitext(os.path.basename(f['name']))
for kb in keyboards:
if kmx_id.lower() == kb['id'] and kmx_id != kb['id']:
os.rename(os.path.join(self.packageDir, f['name']),
os.path.join(self.packageDir, kb['id'] + '.kmx'))
fpath = os.path.join(self.packageDir, kb['id'] + '.kmx')
extractico(fpath)
def _safeMakeDirs(self, dir):
if not os.path.isdir(dir):
try:
os.makedirs(dir)
except NotADirectoryError:
logging.error("Can't create directory %s", dir)
return None
except PermissionError:
logging.error("No permissions to create directory %s", dir)
return None
except Exception as e:
logging.warning('Exception %s creating %s %s', type(e), dir, e.args)
return None
return dir
def _safeLinkFile(self, source, target):
if not os.path.isfile(source):
logging.error("Can't link file %s - source file doesn't exist", source)
return
if os.path.isfile(target):
return
if os.path.exists(target):
logging.error("Can't link file %s to %s - something exists at the target", source, target)
return
os.link(source, target)
def _normalize_language(self, supportedLanguages, language):
if len(supportedLanguages) <= 0:
return ''
if not language:
return language
language = CanonicalLanguageCodeUtils.findBestTag(language, False, False)
for supportedLanguage in supportedLanguages:
tag = CanonicalLanguageCodeUtils.findBestTag(supportedLanguage['id'], False, False)
if tag == language:
return tag
return None
def _add_custom_keyboard(self, keyboard, package_dir, requested_language):
if not requested_language:
return None
language = CanonicalLanguageCodeUtils.findBestTag(requested_language, False, False)
new_keyboard = get_ibus_keyboard_id(keyboard, package_dir, language)
CustomKeyboards().add(new_keyboard)
return language
def _install_keyboards(self, keyboards, packageDir, requested_language=None):
language = requested_language
# TODO: add other keyboards as well (#9757)
firstKeyboard = keyboards[0]
if secure_lookup(firstKeyboard, 'languages') and firstKeyboard['languages']:
language = CanonicalLanguageCodeUtils.normalize_language(firstKeyboard['languages'], language)
if not language:
language = self._add_custom_keyboard(firstKeyboard, packageDir, requested_language)
sentry = SentryErrorHandling()
sentry.add_breadcrumb(category='install', message=f'Installing keyboards for lang: {language}')
if is_fcitx_running():
return self._install_keyboards_to_fcitx()
restart_ibus()
if is_gnome_desktop():
return self._install_keyboards_to_gnome(keyboards, packageDir, language)
else:
return self._install_keyboards_to_ibus(keyboards, packageDir, language)
def _install_keyboards_to_ibus(self, keyboards, packageDir, language=None):
# sourcery skip: split-or-ifs
bus = get_ibus_bus()
if bus or os.environ.get('SUDO_USER'):
# install all kmx for first lang not just packageID
for kb in keyboards:
ibus_keyboard_id = get_ibus_keyboard_id(kb, packageDir, language)
install_to_ibus(bus, ibus_keyboard_id)
restart_ibus(bus)
if bus:
bus.destroy()
else:
logging.debug("could not install keyboards to IBus")
return ''
def _install_keyboards_to_gnome(self, keyboards, packageDir, language=None):
gnomeKeyboardsUtil = GnomeKeyboardsUtil()
sources = gnomeKeyboardsUtil.read_input_sources()
# install all kmx for first lang not just packageID
for kb in keyboards:
ibus_keyboard_id = get_ibus_keyboard_id(kb, packageDir, language)
input_source = ('ibus', ibus_keyboard_id)
if input_source not in sources:
sources.append(input_source)
gnomeKeyboardsUtil.write_input_sources(sources)
return ''
def _install_keyboards_to_fcitx(self):
return _('Please use fcitx5-configtool to add the keyboard to the desired group')
def extract_kmp(kmpfile, directory):
try:
with zipfile.ZipFile(kmpfile, "r") as zip_ref:
zip_ref.extractall(directory)
except zipfile.BadZipFile as e:
raise InstallError(InstallStatus.Abort, e) from e
def process_keyboard_data(keyboardID, packageDir) -> bool:
kbdata = get_keyboard_data(keyboardID)
if not kbdata or len(kbdata) <= 0:
return False
if not os.path.isdir(packageDir) and os.access(os.path.join(packageDir, os.pardir), os.X_OK | os.W_OK):
try:
os.makedirs(packageDir)
except Exception as e:
logging.warning('Exception %s creating %s %s', type(e), packageDir, e.args)
if os.access(packageDir, os.X_OK | os.W_OK):
try:
with open(os.path.join(packageDir, f'{keyboardID}.json'), 'w', encoding='utf-8') as outfile:
json.dump(kbdata, outfile)
logging.info("Installing api data file %s.json as keyman file", keyboardID)
return True
except Exception as e:
logging.warning('Exception %s writing %s/%s.json %s', type(e), packageDir, keyboardID, e.args)
return False
def install_kmp(inputfile, sharedarea=False, language=None):
"""
Install a kmp file
Args:
inputfile (str): path to kmp file
sharedarea(bool, default=False): whether install kmp to shared area or user directory
language(str, default=None): language to install keyboard for
has_ui(bool, default=True): whether we're displaying a window or running UI less from the command line
"""
if sharedarea:
return_value = InstallKmp().install_kmp_shared(inputfile, language)
else:
return_value = InstallKmp().install_kmp_user(inputfile, language)
keyboard_list_changed()
return return_value