Merge pull request #15576 from keymanapp/feat/linux/7784_diagreport

feat(linux): implement diagnostic report

This change allows to collect data for a diagnostic report. This can be done either from the command line through the new `km-diag-report` tool, or in `km-config` from the new `Support` tab that allows to generate the report and then copy it to the clipboard or save as a file.

The diagnostic report contains installed browsers and their versions and installation methods as well as the default browser. It checks for browsers installed through apt (Ubuntu/Debian), snap, and flatpak.
    
The change also has an implementation for pacman (Arch Linux) and dnf/yum (Fedora), although these have not been tested.

Fixes: #7784
This commit is contained in:
Eberhard Beilharz 2026-03-04 10:32:47 +01:00 committed by GitHub
commit bf388295ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1326 additions and 14 deletions

View file

@ -1,4 +1,5 @@
linux/keyman-config/km-config.bash-completion km-config
linux/keyman-config/km-diag-report.bash-completion km-diag-report
linux/keyman-config/km-kvk2ldml.bash-completion km-kvk2ldml
linux/keyman-config/km-package-get.bash-completion km-package-get
linux/keyman-config/km-package-install.bash-completion km-package-install

View file

@ -1,4 +1,5 @@
/usr/share/keyman-config/km-config /usr/bin/km-config
/usr/share/keyman-config/km-diag-report /usr/bin/km-diag-report
/usr/share/keyman-config/km-kvk2ldml /usr/bin/km-kvk2ldml
/usr/share/keyman-config/km-package-get /usr/bin/km-package-get
/usr/share/keyman-config/km-package-install /usr/bin/km-package-install

View file

@ -0,0 +1,533 @@
#!/usr/bin/python3
'''
Keyman is copyright (C) SIL Global. MIT License.
Collects diagnostic information about the system for troubleshooting.
'''
from datetime import datetime
import logging
import os
import platform
import shutil
import subprocess
from keyman_config.get_kmp import InstallLocation
from keyman_config.ibus_util import get_ibus_version, verify_ibus_daemon, IbusDaemon
from keyman_config.list_installed_kmp import get_installed_kmp
from keyman_config.version import (
__version__,
__versionwithtag__,
__pkgversion__,
__tier__
)
class DiagReport:
"""
Collects diagnostic information about the system for troubleshooting.
"""
def _get_keyman_version(self):
"""Get Keyman version information."""
return {
'version': __version__,
'version_with_tag': __versionwithtag__,
'package_version': __pkgversion__,
'tier': __tier__
}
def _get_package_version(self):
"""Get the dpkg package version of keyman."""
try:
result = subprocess.run(
['dpkg-query', '-W', '-f', '${Version}', 'keyman'],
capture_output=True, check=False)
if result.returncode == 0:
return result.stdout.decode('utf-8').strip()
except FileNotFoundError:
logging.debug('dpkg-query not found')
except Exception as e:
logging.warning('Error getting package version: %s', e)
return None
def _get_fcitx_version(self): # sourcery skip: use-next
"""Get the fcitx5 version if available."""
try:
result = subprocess.run(
['fcitx5', '--version'],
capture_output=True, check=False)
if result.returncode == 0:
# Parse output like "fcitx5 version: 5.0.23"
output = result.stdout.decode('utf-8').strip()
for line in output.split('\n'):
if 'version' in line.lower():
return line.split(':')[-1].strip()
return output
except FileNotFoundError:
logging.debug('fcitx5 not found')
except Exception as e:
logging.warning('Error getting fcitx version: %s', e)
return None
def _get_os_info(self):
"""Get OS distribution information."""
os_info = {
'platform': platform.platform(),
'system': platform.system(),
'release': platform.release(),
'machine': platform.machine()
}
# Try to get freedesktop os-release info
try:
self._get_freedesktop_os_release_info(os_info)
except (OSError, AttributeError):
# Fallback to lsb_release
try:
result = subprocess.run(
['lsb_release', '-a'],
capture_output=True, check=False)
if result.returncode == 0:
output = result.stdout.decode('utf-8')
for line in output.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
key = key.strip().lower().replace(' ', '_')
os_info[key] = value.strip()
except FileNotFoundError:
logging.debug('lsb_release not found')
except Exception as e:
logging.warning('Error getting OS info via lsb_release: %s', e)
return os_info
def _get_freedesktop_os_release_info(self, os_info):
os_release = platform.freedesktop_os_release()
os_info['pretty_name'] = os_release.get('PRETTY_NAME', '')
os_info['version_codename'] = os_release.get('VERSION_CODENAME', '')
def _get_display_server(self):
"""Get the display server type (X11, Wayland, etc.)."""
session_type = os.environ.get('XDG_SESSION_TYPE', '')
wayland_display = os.environ.get('WAYLAND_DISPLAY', '')
display = os.environ.get('DISPLAY', '')
return {
'session_type': session_type,
'wayland_display': wayland_display,
'display': display
}
def _get_desktop_environment(self):
"""Get the current desktop environment."""
return {
'xdg_current_desktop': os.environ.get('XDG_CURRENT_DESKTOP', ''),
'xdg_session_desktop': os.environ.get('XDG_SESSION_DESKTOP', ''),
'desktop_session': os.environ.get('DESKTOP_SESSION', '')
}
def _get_input_method(self):
"""Get current input method configuration."""
return {
'gtk_im_module': os.environ.get('GTK_IM_MODULE', ''),
'qt_im_module': os.environ.get('QT_IM_MODULE', ''),
'xmodifiers': os.environ.get('XMODIFIERS', ''),
}
def _get_installed_keyboards(self):
"""Get list of installed Keyman keyboards."""
keyboards = {
'user': [],
'shared': [],
'os': []
}
for area, location in [
('user', InstallLocation.User),
('shared', InstallLocation.Shared),
('os', InstallLocation.OS)
]:
try:
installed = get_installed_kmp(location)
for pkg_id, data in installed.items():
keyboards[area].append({
'id': pkg_id,
'name': data.get('name', ''),
'version': data.get('kmpversion', '')
})
except Exception as e:
logging.warning('Error getting keyboards from %s: %s', area, e)
return keyboards
def _get_ibus_engines(self):
"""Get list of IBus preload engines."""
try:
from keyman_config.gsettings import GSettings
ibus_settings = GSettings('org.freedesktop.ibus.general')
engines = ibus_settings.get('preload-engines')
return engines or []
except Exception as e:
logging.warning('Error getting IBus engines: %s', e)
return []
def _get_gnome_input_sources(self):
"""Get Gnome input sources if running Gnome."""
try:
from keyman_config.gnome_keyboards_util import GnomeKeyboardsUtil, is_gnome_desktop
if is_gnome_desktop():
util = GnomeKeyboardsUtil()
sources = util.read_input_sources()
return sources or []
except Exception as e:
logging.warning('Error getting Gnome input sources: %s', e)
return []
def _get_browser_versions(self):
"""Get versions of Firefox, Chromium, Chrome installed via various package managers."""
return {
'apt': self._get_apt_browser_versions(),
'rpm': self._get_rpm_browser_versions(),
'pacman': self._get_pacman_browser_versions(),
'snap': self._get_snap_browser_versions(),
'flatpak': self._get_flatpak_browser_versions(),
}
def _get_snap_browser_versions(self):
"""Get browser versions installed via snap."""
snap_browsers = {}
for browser in ['firefox', 'chromium']:
try:
result = subprocess.run(
['snap', 'info', browser],
capture_output=True,
check=False,
)
if result.returncode == 0:
output = result.stdout.decode('utf-8')
for line in output.split('\n'):
if line.startswith('installed:'):
version = line.split(':', 1)[1].strip()
if version != '-':
snap_browsers[browser] = version
break
except FileNotFoundError:
logging.debug('snap not found')
except Exception as e:
logging.debug('Error checking snap package %s: %s', browser, e)
return snap_browsers
def _get_flatpak_browser_versions(self):
"""Get browser versions installed via flatpak."""
flatpak_browsers = {}
for app_id in ['org.mozilla.firefox', 'org.chromium.Chromium', 'com.google.Chrome']:
try:
result = subprocess.run(
['flatpak', 'info', app_id],
capture_output=True,
check=False,
)
if result.returncode == 0:
output = result.stdout.decode('utf-8')
for line in output.split('\n'):
if line.strip().startswith('Version:'):
version = line.split(':', 1)[1].strip()
app_name = app_id.split('.')[-1].lower()
flatpak_browsers[app_name] = version
break
except FileNotFoundError:
logging.debug('flatpak not found')
except Exception as e:
logging.debug('Error checking flatpak app %s: %s', app_id, e)
return flatpak_browsers
def _has_command(self, cmd):
result = shutil.which(cmd)
if not result:
logging.debug(f'{cmd} not found')
return False
return True
def _get_apt_browser_versions(self):
"""Get browser versions installed via apt."""
apt_browsers = {}
if not self._has_command('dpkg-query'):
return apt_browsers
for browser in ['firefox', 'chromium-browser', 'chromium', 'google-chrome-stable']:
try:
result = subprocess.run(
['dpkg-query', '-W', '-f', '${Version}', browser],
capture_output=True,
check=False,
)
if result.returncode == 0:
if version := result.stdout.decode('utf-8').strip():
apt_browsers[browser] = version
except Exception as e:
logging.debug('Error checking apt package %s: %s', browser, e)
return apt_browsers
def _get_rpm_browser_versions(self):
"""Get browser versions installed via RPM-based package managers (dnf/yum)."""
rpm_browsers = {}
pm_command = next(
(cmd for cmd in ['dnf', 'yum'] if self._has_command(cmd)), None
)
if not pm_command:
logging.debug('Neither dnf nor yum package manager found')
return rpm_browsers
for browser in ['firefox', 'chromium', 'google-chrome-stable']:
try:
result = subprocess.run(
[pm_command, 'info', 'installed', browser],
capture_output=True,
check=False,
)
if result.returncode == 0:
output = result.stdout.decode('utf-8')
for line in output.split('\n'):
if line.startswith('Version'):
version = line.split(':', 1)[1].strip()
rpm_browsers[browser] = version
break
except Exception as e:
logging.debug('Error checking rpm package %s: %s', browser, e)
return rpm_browsers
def _get_pacman_browser_versions(self):
"""Get browser versions installed via Pacman (Arch Linux)."""
pacman_browsers = {}
if not self._has_command('pacman'):
return pacman_browsers
for browser in ['firefox', 'chromium', 'google-chrome']:
try:
result = subprocess.run(
['pacman', '-Q', browser],
capture_output=True,
check=False,
)
if result.returncode == 0:
output = result.stdout.decode('utf-8').strip()
# pacman -Q outputs: "package version"
parts = output.split()
if len(parts) >= 2:
version = parts[1]
pacman_browsers[browser] = version
except Exception as e:
logging.debug('Error checking pacman package %s: %s', browser, e)
return pacman_browsers
def _get_default_browser(self):
"""Get the default browser with executable path."""
try:
result = subprocess.run(
['xdg-mime', 'query', 'default', 'text/html'],
capture_output=True, check=False)
if result.returncode == 0:
if desktop_id := result.stdout.decode('utf-8').strip():
if desktop_info := self._parse_desktop_file(desktop_id):
return desktop_info
return desktop_id
except FileNotFoundError:
logging.debug('xdg-mime not found')
except Exception as e:
logging.warning('Error getting default browser: %s', e)
return None
def _parse_desktop_file(self, desktop_id):
"""Parse a .desktop file and extract the Exec command and file path."""
# Common locations for .desktop files
search_paths = [
os.path.expanduser('~/.local/share/applications'),
'/usr/local/share/applications',
'/usr/share/applications'
]
if 'XDG_DATA_DIRS' in os.environ:
data_dirs = os.environ['XDG_DATA_DIRS']
new_search_paths = []
new_search_paths.extend(
os.path.join(data_dir, 'applications')
for data_dir in data_dirs.split(os.pathsep)
)
search_paths = new_search_paths + search_paths
if 'XDG_DATA_HOME' in os.environ:
search_paths.insert(0, os.path.join(os.environ['XDG_DATA_HOME'], 'applications'))
for base_path in search_paths:
desktop_file = os.path.join(base_path, desktop_id)
if os.path.isfile(desktop_file):
try:
with open(desktop_file, 'r', encoding='utf-8') as f:
exec_cmd = None
for line in f:
if line.startswith('Exec='):
exec_cmd = line.split('=', 1)[1].strip()
# Remove field codes like %U, %F, %u, %f
exec_cmd = exec_cmd.split('%')[0].strip()
break
return f"{desktop_file} ({exec_cmd})" if exec_cmd else f"{desktop_file}"
except Exception as e:
logging.debug('Error parsing desktop file %s: %s', desktop_file, e)
return None
def _report_browsers(self, pkg_managers, browsers):
lines = []
for (key, description) in pkg_managers:
if browsers.get(key):
lines.append(f' {description}:')
lines.extend(
f" - {name}: {version}"
for name, version in browsers[key].items()
)
return lines or None
def _get_ibus_state(self):
ibus_state = verify_ibus_daemon(False)
match ibus_state:
case IbusDaemon.RUNNING:
return "Running"
case IbusDaemon.NOT_RUNNING:
return "Not running"
case IbusDaemon.MORE_THAN_ONE:
return "More than one instance running"
case IbusDaemon.ERROR_USER:
return "invalid/unknown user"
case IbusDaemon.ERROR:
return "got exception finding ibus-daemon"
case _:
return f"Unknown state: {ibus_state}"
def _create_report(self): # sourcery skip: low-code-quality
"""Generate a complete diagnostic report as a formatted string."""
keyman_info = self._get_keyman_version()
lines = [
"=" * 60,
f"Keyman for Linux Diagnostic Report ({datetime.now().strftime('%Y-%m-%d')})",
"=" * 60,
"",
"--- Keyman Version ---",
*(
f" Version: {keyman_info['version_with_tag']}",
f" Tier: {keyman_info['tier']}",
),
]
if keyman_info['package_version']:
lines.append(f" Package version: {keyman_info['package_version']}")
if pkg_version := self._get_package_version():
lines.append(f" Installed package (dpkg): {pkg_version}")
# IBus Version
ibus_version = get_ibus_version()
if ibus_version is not None:
lines.extend(("", "--- IBus ---", f" IBus version: {ibus_version or 'unknown'} ({self._get_ibus_state()})"))
if engines := self._get_ibus_engines():
lines.append(f" Preload engines: {', '.join(engines)}")
if fcitx_version := self._get_fcitx_version():
lines.extend(("", "--- Fcitx5 ---", f" Fcitx5 version: {fcitx_version}"))
# OS Information
lines.extend(("", "--- Operating System ---"))
os_info = self._get_os_info()
if 'pretty_name' in os_info and os_info['pretty_name']:
lines.append(f" OS: {os_info['pretty_name']}")
else:
lines.append(f" Platform: {os_info['platform']}")
if os_info.get('version_codename'):
lines.append(f" Codename: {os_info['version_codename']}")
lines.extend((
f" Kernel: {os_info['release']}",
f" Architecture: {os_info['machine']}",
# Display Server
"",
"--- Display Server ---"
))
display_info = self._get_display_server()
lines.append(f" Session type: {display_info['session_type'] or 'Not set'}")
if display_info['wayland_display']:
lines.append(f" Wayland display: {display_info['wayland_display']}")
if display_info['display']:
lines.append(f" X11 display: {display_info['display']}")
# Desktop Environment
lines.extend(("", "--- Desktop Environment ---"))
desktop_info = self._get_desktop_environment()
if desktop_info['xdg_current_desktop']:
lines.append(f" Current desktop: {desktop_info['xdg_current_desktop']}")
if desktop_info['xdg_session_desktop']:
lines.append(f" Session desktop: {desktop_info['xdg_session_desktop']}")
if desktop_info['desktop_session']:
lines.append(f" Desktop session: {desktop_info['desktop_session']}")
# Input Method
lines.extend(("", "--- Input Method Configuration ---"))
im_info = self._get_input_method()
lines.extend((
f" GTK_IM_MODULE: {im_info['gtk_im_module'] or 'Not set'}",
f" QT_IM_MODULE: {im_info['qt_im_module'] or 'Not set'}",
f" XMODIFIERS: {im_info['xmodifiers'] or 'Not set'}",
))
if gnome_sources := self._get_gnome_input_sources():
lines.extend(("", "--- Gnome Input Sources ---"))
for source in gnome_sources:
if isinstance(source, tuple) and len(source) == 2:
lines.append(f" {source[0]}: {source[1]}")
else:
lines.append(f" {source}")
# Installed Keyboards
lines.extend(("", "--- Installed Keyman Keyboards ---"))
keyboards = self._get_installed_keyboards()
for area in ['user', 'shared', 'os']:
if keyboards[area]:
lines.append(f" {area.capitalize()} keyboards:")
lines.extend(
f" - {kb['name']} (id: {kb['id']}, version: {kb['version']})"
for kb in keyboards[area]
)
if not any(keyboards.values()):
lines.append("No Keyman keyboards installed")
# Browsers
lines.extend(("", "--- Web Browsers ---"))
browsers = self._get_browser_versions()
pkg_managers = [
('apt', 'APT packages'),
('rpm', 'RPM packages (dnf/yum)'),
('pacman', 'Pacman packages (Arch)'),
('snap', 'Snap packages'),
('flatpak', 'Flatpak packages'),
]
if browser_report := self._report_browsers(pkg_managers, browsers):
lines.extend(browser_report)
else:
lines.append("No Firefox, Chromium, or Chrome installations found")
# Default browser
if default_browser := self._get_default_browser():
lines.extend((" Default browser:", f" {default_browser}"))
else:
lines.append(" No default browser detected")
lines.extend((
"",
"=" * 60,
"End of Diagnostic Report",
"=" * 60
))
return '\n'.join(lines)
def get_diagnostic_report():
"""Convenience function to generate a diagnostic report."""
return DiagReport()._create_report()

View file

@ -143,20 +143,38 @@ def verify_ibus_daemon(start):
return retval
def _get_ibus_version():
ibus_version = subprocess.run(('ibus', 'version'), check=False,
stdout=subprocess.PIPE).stdout
match = re.search(r'^IBus (.*)\n$', ibus_version.decode('utf-8'))
if match:
logging.info('Running IBus version %s', match.group(1))
return match.group(1)
logging.warning('Unable to determine IBus version')
return ''
def get_ibus_version():
"""
Get the installed IBus version string if available. This checks the `ibus` command
output and parses the reported version.
Returns the version as a string on success, an empty string if the version
cannot be determined, or None if the `ibus` command is not available.
Returns:
str | None: The detected IBus version string, an empty string if
parsing fails, or None if the `ibus` command is not found.
"""
try:
ibus_version = subprocess.run(('ibus', 'version'), check=False,
stdout=subprocess.PIPE).stdout
if match := re.search(r'^IBus (.*)\n$', ibus_version.decode('utf-8')):
logging.info('Running IBus version %s', match[1])
return match[1]
logging.warning('Unable to determine IBus version')
return ''
except FileNotFoundError:
logging.warning('ibus command not found')
return None
def _start_ibus_daemon(realuser):
try:
if version.parse(_get_ibus_version()) >= version.parse('1.5.28'):
ibus_ver = get_ibus_version()
if not ibus_ver:
return
if version.parse(ibus_ver) >= version.parse('1.5.28'):
# IBus ~1.5.28 added the `start` command, so we use that if possible
# and let IBus deal with the necessary parameters
args = ['ibus', 'start', '-d']

View file

@ -0,0 +1,182 @@
#!/usr/bin/python3
'''
Keyman is copyright (C) SIL Global. MIT License.
'''
import gi
from datetime import datetime
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from keyman_config import _
from keyman_config.diag_report import get_diagnostic_report
class SupportWidget(Gtk.Box):
"""Widget for the Support tab in km-config that shows diagnostic information."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
# Header label
label = Gtk.Label(_("Support"))
label.set_padding(5, 5)
label.set_halign(Gtk.Align.START)
self.pack_start(label, False, False, 10)
# Description
desc_label = Gtk.Label()
desc_label.set_markup(
_("Generate a diagnostic report to help troubleshoot issues with Keyman. "
"You can copy this report and include it when reporting issues.\n\n"
"This report does not contain personally identifiable information, "
"but it will include system information and Keyman configuration details. "
"See the <a href='https://software.sil.org/privacy-policy/'>privacy policy</a>."))
desc_label.set_use_markup(True)
desc_label.set_line_wrap(True)
desc_label.set_halign(Gtk.Align.START)
desc_label.set_padding(5, 5)
self.pack_start(desc_label, False, False, 0)
# Button box
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
button_box.set_margin_start(5)
button_box.set_margin_top(10)
self.generate_button = Gtk.Button.new_with_mnemonic(_("_Generate Report"))
self.generate_button.set_tooltip_text(_("Generate a diagnostic report"))
self.generate_button.connect("clicked", self.on_generate_clicked)
button_box.pack_start(self.generate_button, False, False, 0)
self.copy_button = Gtk.Button.new_with_mnemonic(_("_Copy to Clipboard"))
self.copy_button.set_tooltip_text(_("Copy the report to clipboard"))
self.copy_button.connect("clicked", self.on_copy_clicked)
self.copy_button.set_sensitive(False)
button_box.pack_start(self.copy_button, False, False, 0)
self.save_button = Gtk.Button.new_with_mnemonic(_("_Save to File..."))
self.save_button.set_tooltip_text(_("Save the report to a file"))
self.save_button.connect("clicked", self.on_save_clicked)
self.save_button.set_sensitive(False)
button_box.pack_start(self.save_button, False, False, 0)
self.pack_start(button_box, False, False, 0)
# Scrolled window with text view for the report
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_margin_start(5)
scrolled.set_margin_end(5)
scrolled.set_margin_top(10)
scrolled.set_margin_bottom(5)
self.text_view = Gtk.TextView()
self.text_view.set_editable(False)
self.text_view.set_cursor_visible(False)
self.text_view.set_monospace(True)
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
self.text_buffer = self.text_view.get_buffer()
scrolled.add(self.text_view)
self.pack_start(scrolled, True, True, 0)
self.report_text = ""
def on_generate_clicked(self, button):
"""Generate the diagnostic report and display it."""
# Show a spinner or progress indication
self.generate_button.set_sensitive(False)
self.text_buffer.set_text(_("Generating report..."))
# Use idle_add to allow the UI to update
from gi.repository import GLib
GLib.idle_add(self._generate_report)
def _generate_report(self):
"""Actually generate the report (called from idle)."""
try:
self.report_text = get_diagnostic_report()
self.text_buffer.set_text(self.report_text)
self.copy_button.set_sensitive(True)
self.save_button.set_sensitive(True)
except Exception as e:
self.text_buffer.set_text(_("Error generating report: ") + str(e))
finally:
self.generate_button.set_sensitive(True)
return False # Don't repeat
def on_copy_clicked(self, button):
"""Copy the report to clipboard."""
if self.report_text:
clipboard = Gtk.Clipboard.get_default(self.get_display())
clipboard.set_text(self.report_text, -1)
# Show a brief notification
self._show_toast(_("Report copied to clipboard"))
def on_save_clicked(self, button):
"""Save the report to a file."""
if not self.report_text:
return
dialog = Gtk.FileChooserDialog(
title=_("Save Diagnostic Report"),
parent=self.get_toplevel(),
action=Gtk.FileChooserAction.SAVE)
dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
dialog.set_do_overwrite_confirmation(True)
date_str = datetime.now().strftime("%Y-%m-%d")
dialog.set_current_name(f"keyman-diagnostic-report_{date_str}.txt")
filter_text = Gtk.FileFilter()
filter_text.set_name(_("Text files"))
filter_text.add_pattern("*.txt")
dialog.add_filter(filter_text)
filter_all = Gtk.FileFilter()
filter_all.set_name(_("All files"))
filter_all.add_pattern("*")
dialog.add_filter(filter_all)
response = dialog.run()
if response == Gtk.ResponseType.OK:
filename = dialog.get_filename()
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(self.report_text)
f.write('\n')
self._show_toast(_("Report saved to ") + filename)
except IOError as e:
error_dialog = Gtk.MessageDialog(
parent=self.get_toplevel(),
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text=_("Error saving file"))
error_dialog.format_secondary_text(str(e))
error_dialog.run()
error_dialog.destroy()
dialog.destroy()
def _show_toast(self, message):
"""Show a brief notification message."""
# For simplicity, we'll just update a label temporarily
# A more sophisticated implementation could use GtkRevealer or similar
from gi.repository import GLib
original_text = _("_Generate Report")
self.generate_button.set_use_underline(False)
self.generate_button.set_label(message)
self.generate_button.set_sensitive(False)
def restore():
self.generate_button.set_label(original_text)
self.generate_button.set_sensitive(True)
self.generate_button.set_use_underline(True)
return False
GLib.timeout_add(2000, restore)

View file

@ -30,6 +30,7 @@ from keyman_config.keyboard_layouts_widget import KeyboardLayoutsWidget
from keyman_config.list_installed_kmp import get_installed_kmp
from keyman_config.options_widget import OptionsWidget
from keyman_config.sentry_handling import SentryErrorHandling
from keyman_config.support_widget import SupportWidget
class ViewInstalledWindowBase(Gtk.Window):
@ -154,6 +155,7 @@ class ViewInstalledWindow(ViewInstalledWindowBase):
self.refresh_installed_kmp()
stack.add_titled(KeyboardLayoutsWidget(self, self.store, self.restart), "KeyboardLayouts", _("Keyboard Layouts"))
stack.add_titled(OptionsWidget(self.sentry), "Options", _("Options"))
stack.add_titled(SupportWidget(), "Support", _("Support"))
return outerHbox
def _add_app_buttons(self):

View file

@ -0,0 +1,39 @@
#!/usr/bin/python3
import argparse
import sys
from keyman_config import add_standard_arguments, initialize_logging, initialize_sentry
from keyman_config.diag_report import get_diagnostic_report
def main():
parser = argparse.ArgumentParser(
description='Generate a diagnostic report of Keyman and system configuration. '
'This report can be used for troubleshooting Keyman issues.')
parser.add_argument('-o', '--output', metavar='FILE',
help='write report to FILE instead of stdout')
add_standard_arguments(parser)
args = parser.parse_args()
initialize_logging(args)
initialize_sentry()
report = get_diagnostic_report()
if args.output:
try:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(report)
f.write('\n')
print(f"Diagnostic report written to {args.output}")
except IOError as e:
print(f"Error writing to file: {e}", file=sys.stderr)
sys.exit(1)
else:
print(report)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,18 @@
# shellcheck disable=SC2148
# No hashbang for bash completion scripts! They are intended to be sourced, not executed.
_km-diag-report_completions()
{
local cur opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
opts="-h --help -o --output -v --verbose -vv --veryverbose --version"
if [[ ${cur} == -* ]] ; then
# shellcheck disable=SC2207
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
}
complete -F _km-diag-report_completions km-diag-report

View file

@ -1,5 +1,5 @@
[<DESCRIPTION]
Keyman makes it possible for you to type in over 1,000 languages on Windows, Linux,
Keyman makes it possible for you to type in over 2,500 languages on Windows, Linux,
macOS, iPhone, iPad, Android tablets and phones, and even instantly in your web browser.
With the world's most powerful keyboarding engine, intuitive and rapid text input is now

View file

@ -12,7 +12,8 @@ setup(
scripts=['km-config', 'km-package-get',
'km-package-install', 'km-kvk2ldml',
'km-package-uninstall',
'km-package-list-installed'],
'km-package-list-installed',
'km-diag-report'],
zip_safe=False, # don't install a zip

View file

@ -0,0 +1,517 @@
#!/usr/bin/env python3
import os
import tempfile
import unittest
from unittest.mock import Mock, patch, MagicMock
from keyman_config.diag_report import DiagReport, get_diagnostic_report
class DiagReportTests(unittest.TestCase):
def test_get_keyman_version__returns_dict(self):
# Execute
diag = DiagReport()
result = diag._get_keyman_version()
# Verify
self.assertIsInstance(result, dict)
self.assertIn('version', result)
self.assertIn('version_with_tag', result)
self.assertIn('package_version', result)
self.assertIn('tier', result)
@patch('subprocess.run')
def test_get_package_version__success(self, patched_subprocess_run):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'19.0.201-1',
returncode=0
)
# Execute
diag = DiagReport()
result = diag._get_package_version()
# Verify
self.assertEqual(result, '19.0.201-1')
patched_subprocess_run.assert_called_once_with(
['dpkg-query', '-W', '-f', '${Version}', 'keyman'],
capture_output=True, check=False)
@patch('subprocess.run')
def test_get_package_version__not_installed(self, patched_subprocess_run):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'',
returncode=1
)
# Execute
diag = DiagReport()
result = diag._get_package_version()
# Verify
self.assertIsNone(result)
@patch('subprocess.run')
def test_get_fcitx_version__success(self, patched_subprocess_run):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'fcitx5 version: 5.0.23\n',
returncode=0
)
# Execute
diag = DiagReport()
result = diag._get_fcitx_version()
# Verify
self.assertEqual(result, '5.0.23')
@patch('subprocess.run')
def test_get_fcitx_version__not_installed(self, patched_subprocess_run):
# Setup
patched_subprocess_run.side_effect = FileNotFoundError()
# Execute
diag = DiagReport()
result = diag._get_fcitx_version()
# Verify
self.assertIsNone(result)
def test_get_os_info__returns_dict(self):
# Execute
diag = DiagReport()
result = diag._get_os_info()
# Verify
self.assertIsInstance(result, dict)
self.assertIn('platform', result)
self.assertIn('system', result)
self.assertIn('release', result)
self.assertIn('machine', result)
@patch.dict(os.environ, {'XDG_SESSION_TYPE': 'wayland', 'WAYLAND_DISPLAY': 'wayland-0', 'DISPLAY': ':0'})
def test_get_display_server(self):
# Execute
diag = DiagReport()
result = diag._get_display_server()
# Verify
self.assertEqual(result['session_type'], 'wayland')
self.assertEqual(result['wayland_display'], 'wayland-0')
self.assertEqual(result['display'], ':0')
@patch.dict(os.environ, {'XDG_CURRENT_DESKTOP': 'GNOME', 'XDG_SESSION_DESKTOP': 'gnome'})
def test_get_desktop_environment(self):
# Execute
diag = DiagReport()
result = diag._get_desktop_environment()
# Verify
self.assertEqual(result['xdg_current_desktop'], 'GNOME')
self.assertEqual(result['xdg_session_desktop'], 'gnome')
@patch.dict(os.environ, {'GTK_IM_MODULE': 'ibus', 'QT_IM_MODULE': 'ibus', 'XMODIFIERS': '@im=ibus'})
def test_get_input_method(self):
# Execute
diag = DiagReport()
result = diag._get_input_method()
# Verify
self.assertEqual(result['gtk_im_module'], 'ibus')
self.assertEqual(result['qt_im_module'], 'ibus')
self.assertEqual(result['xmodifiers'], '@im=ibus')
@patch('keyman_config.diag_report.get_installed_kmp')
def test_get_installed_keyboards(self, patched_get_installed_kmp):
# Setup
patched_get_installed_kmp.return_value = {
'sil_euro_latin': {
'name': 'EuroLatin',
'kmpversion': '2.0.1'
}
}
# Execute
diag = DiagReport()
result = diag._get_installed_keyboards()
# Verify
self.assertIsInstance(result, dict)
self.assertIn('user', result)
self.assertIn('shared', result)
self.assertIn('os', result)
@patch('keyman_config.diag_report.get_installed_kmp')
def test_get_installed_keyboards__with_keyboards(self, patched_get_installed_kmp):
# Setup - only return data for user area
def mock_get_installed_kmp(location):
from keyman_config.get_kmp import InstallLocation
# sourcery skip: no-conditionals-in-tests
if location == InstallLocation.User:
return {
'sil_euro_latin': {
'name': 'EuroLatin',
'kmpversion': '2.0.1'
}
}
return {}
patched_get_installed_kmp.side_effect = mock_get_installed_kmp
# Execute
diag = DiagReport()
result = diag._get_installed_keyboards()
# Verify
self.assertEqual(len(result['user']), 1)
self.assertEqual(result['user'][0]['id'], 'sil_euro_latin')
self.assertEqual(result['user'][0]['name'], 'EuroLatin')
self.assertEqual(result['user'][0]['version'], '2.0.1')
self.assertEqual(len(result['shared']), 0)
self.assertEqual(len(result['os']), 0)
@patch('keyman_config.diag_report.get_ibus_version')
@patch('keyman_config.diag_report.get_installed_kmp')
def test_create_report__returns_string(self, patched_get_installed_kmp, patched_ibus_version):
# Setup
patched_ibus_version.return_value = '1.5.28'
patched_get_installed_kmp.return_value = {}
# Execute
diag = DiagReport()
result = diag._create_report()
# Verify
self.assertIsInstance(result, str)
self.assertIn('Keyman for Linux Diagnostic Report', result)
self.assertIn('Keyman Version', result)
self.assertIn('IBus', result)
self.assertIn('Operating System', result)
self.assertIn('Display Server', result)
@patch('keyman_config.diag_report.get_ibus_version')
@patch('keyman_config.diag_report.get_installed_kmp')
def test_get_diagnostic_report__convenience_function(self, patched_get_installed_kmp, patched_ibus_version):
# Setup
patched_ibus_version.return_value = '1.5.28'
patched_get_installed_kmp.return_value = {}
# Execute
result = get_diagnostic_report()
# Verify
self.assertIsInstance(result, str)
self.assertIn('Keyman for Linux Diagnostic Report', result)
@patch.object(DiagReport, '_parse_desktop_file')
@patch('subprocess.run')
def test_get_default_browser__success(self, patched_subprocess_run, mock_parse_desktop_file):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'firefox.desktop\n',
returncode=0
)
mock_parse_desktop_file.return_value = None # Parse returns None, fallback to desktop_id
# Execute
diag = DiagReport()
result = diag._get_default_browser()
# Verify
self.assertEqual(result, 'firefox.desktop')
patched_subprocess_run.assert_called_once_with(
['xdg-mime', 'query', 'default', 'text/html'],
capture_output=True, check=False)
@patch.object(DiagReport, '_parse_desktop_file')
@patch('subprocess.run')
def test_get_default_browser__with_desktop_file_parsing(self, patched_subprocess_run, mock_parse_desktop_file):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'firefox.desktop\n',
returncode=0
)
mock_parse_desktop_file.return_value = '/usr/share/applications/firefox.desktop (/usr/bin/firefox)'
# Execute
diag = DiagReport()
result = diag._get_default_browser()
# Verify
self.assertEqual('/usr/share/applications/firefox.desktop (/usr/bin/firefox)', result)
mock_parse_desktop_file.assert_called_once_with('firefox.desktop')
@patch('subprocess.run')
def test_get_default_browser__not_set(self, patched_subprocess_run):
# Setup
patched_subprocess_run.return_value = Mock(
stdout=b'',
returncode=0
)
# Execute
diag = DiagReport()
result = diag._get_default_browser()
# Verify
self.assertIsNone(result)
@patch('subprocess.run')
def test_get_default_browser__xdg_mime_not_found(self, patched_subprocess_run):
# Setup
patched_subprocess_run.side_effect = FileNotFoundError()
# Execute
diag = DiagReport()
result = diag._get_default_browser()
# Verify
self.assertIsNone(result)
def test_parse_desktop_file__parsing_logic(self):
# Test the parsing logic with a simulated file content
# This tests that when a file is found, it extracts Exec and path correctly
diag = DiagReport()
# Test on a file we know exists (even if it's just checking the logic runs)
result = diag._parse_desktop_file('nonexistent-browser.desktop')
# It should return None if file doesn't exist
self.assertIsNone(result)
def test_parse_desktop_file__with_xdg_data_home(self):
# Test that XDG_DATA_HOME is checked when set
with tempfile.TemporaryDirectory() as tmpdir:
# Create applications subdirectory
apps_dir = os.path.join(tmpdir, 'applications')
os.makedirs(apps_dir)
# Create a test .desktop file
desktop_path = os.path.join(apps_dir, 'testbrowser.desktop')
self._create_desktop_file(desktop_path, 'TestBrowser', '/usr/bin/testbrowser %U')
# Set XDG_DATA_HOME and test
with patch.dict(os.environ, {'XDG_DATA_HOME': tmpdir}, clear=False):
diag = DiagReport()
result = diag._parse_desktop_file('testbrowser.desktop')
# Verify the file was found and parsed
self.assertEqual(result, f'{desktop_path} (/usr/bin/testbrowser)')
def test_parse_desktop_file__with_xdg_data_dirs(self):
# Test that XDG_DATA_DIRS is checked when set
with tempfile.TemporaryDirectory() as tmpdir:
# Create applications subdirectory
apps_dir = os.path.join(tmpdir, 'applications')
os.makedirs(apps_dir)
# Create a test .desktop file
desktop_path = os.path.join(apps_dir, 'mybrowser.desktop')
self._create_desktop_file(desktop_path, 'MyBrowser', '/opt/mybrowser/bin/mybrowser')
# Set XDG_DATA_DIRS and test
with patch.dict(os.environ, {'XDG_DATA_DIRS': tmpdir}, clear=False):
diag = DiagReport()
result = diag._parse_desktop_file('mybrowser.desktop')
# Verify the file was found and parsed
self.assertEqual(result, f'{desktop_path} (/opt/mybrowser/bin/mybrowser)')
def test_parse_desktop_file__with_both_xdg_vars(self):
# Test that both XDG_DATA_HOME and XDG_DATA_DIRS are used
with tempfile.TemporaryDirectory() as tmpdir1:
with tempfile.TemporaryDirectory() as tmpdir2:
# Create applications subdirectory in XDG_DATA_HOME
home_apps_dir = os.path.join(tmpdir1, 'applications')
os.makedirs(home_apps_dir)
# Create applications subdirectory in XDG_DATA_DIRS
dirs_apps_dir = os.path.join(tmpdir2, 'applications')
os.makedirs(dirs_apps_dir)
# Create a test .desktop file in XDG_DATA_HOME
home_desktop = os.path.join(home_apps_dir, 'homebrowser.desktop')
self._create_desktop_file(home_desktop, 'HomeBrowser', '/home/user/.local/bin/homebrowser')
# Create a test .desktop file in XDG_DATA_DIRS
dirs_desktop = os.path.join(dirs_apps_dir, 'dirsbrowser.desktop')
self._create_desktop_file(dirs_desktop, 'DirsBrowser', '/usr/bin/dirsbrowser')
# Create a test .desktop file in XDG_DATA_DIRS with same name as in XDG_DATA_HOME
home_desktop2 = os.path.join(dirs_apps_dir, 'homebrowser.desktop')
self._create_desktop_file(home_desktop2, 'HomeBrowser', '/usr/bin/wrongbrowser')
# Set both XDG variables
with patch.dict(os.environ, {
'XDG_DATA_HOME': tmpdir1,
'XDG_DATA_DIRS': tmpdir2
}, clear=False):
diag = DiagReport()
# Test finding file from XDG_DATA_HOME
result1 = diag._parse_desktop_file('homebrowser.desktop')
self.assertEqual(result1, f'{home_desktop} (/home/user/.local/bin/homebrowser)')
# Test finding file from XDG_DATA_DIRS
result2 = diag._parse_desktop_file('dirsbrowser.desktop')
self.assertEqual(result2, f'{dirs_desktop} (/usr/bin/dirsbrowser)')
def _create_desktop_file(self, filename, name, exe):
with open(filename, 'w') as f:
f.write('[Desktop Entry]\n')
f.write(f'Name={name}\n')
f.write(f'Exec={exe}\n')
f.write('Type=Application\n')
def test_parse_desktop_file__not_found(self):
# Setup
with tempfile.TemporaryDirectory() as tmpdir:
with patch('keyman_config.diag_report.os.path.expanduser') as mock_expanduser:
with patch('keyman_config.diag_report.os.path.isfile') as mock_isfile:
mock_expanduser.side_effect = lambda x: x.replace('~', tmpdir)
mock_isfile.return_value = False
# Execute
diag = DiagReport()
result = diag._parse_desktop_file('nonexistent.desktop')
# Verify
self.assertIsNone(result)
@patch('subprocess.run')
@patch('shutil.which')
def test_get_apt_browser_versions__success(self, patched_shutil_which, patched_subprocess_run):
# Setup - mock dpkg-query available and finding firefox and chromium
def mock_which(cmd):
return '/usr/bin/dpkg-query' if cmd == 'dpkg-query' else None
def mock_run(cmd, *args, **kwargs):
# sourcery skip: no-conditionals-in-tests
if cmd[0] == 'dpkg-query':
if 'firefox' in cmd:
return Mock(returncode=0, stdout=b'115.0.1-1')
elif 'chromium' in cmd:
return Mock(returncode=0, stdout=b'120.0.0-1')
return Mock(returncode=1, stdout=b'')
patched_shutil_which.side_effect = mock_which
patched_subprocess_run.side_effect = mock_run
# Execute
diag = DiagReport()
result = diag._get_apt_browser_versions()
# Verify
self.assertIsInstance(result, dict)
self.assertEqual(result['firefox'], '115.0.1-1')
self.assertEqual(result['chromium'], '120.0.0-1')
@patch('shutil.which')
def test_get_apt_browser_versions__not_found(self, patched_shutil_which):
# Setup - mock dpkg-query not available
patched_shutil_which.return_value = None
# Execute
diag = DiagReport()
result = diag._get_apt_browser_versions()
# Verify
self.assertEqual(result, {})
@patch('subprocess.run')
@patch('shutil.which')
def test_get_rpm_browser_versions__with_dnf(self, patched_shutil_which, patched_subprocess_run):
# Setup - mock dnf available and finding firefox
def mock_run(cmd, *args, **kwargs):
# sourcery skip: no-conditionals-in-tests
if cmd[0] == 'dnf' and cmd[1] == 'info' and 'firefox' in cmd:
return Mock(returncode=0, stdout=b'Name: firefox\nVersion: 115.0.1')
return Mock(returncode=1, stdout=b'')
patched_subprocess_run.side_effect = mock_run
patched_shutil_which.return_value = '/path/to/command'
# Execute
diag = DiagReport()
result = diag._get_rpm_browser_versions()
# Verify
self.assertIsInstance(result, dict)
self.assertEqual(result['firefox'], '115.0.1')
@patch('subprocess.run')
@patch('shutil.which')
def test_get_rpm_browser_versions__with_yum(self, patched_shutil_which, patched_subprocess_run):
# Setup - mock dnf not available, yum available
def mock_which(cmd):
# dnf not found, yum found
return '/path/to/yum' if cmd == 'yum' else None
def mock_run(cmd, *args, **kwargs):
# sourcery skip: no-conditionals-in-tests
if cmd[0] == 'yum' and cmd[1] == 'info' and 'chromium' in cmd:
return Mock(returncode=0, stdout=b'Name: chromium\nVersion: 115.0.0')
return Mock(returncode=1, stdout=b'')
patched_shutil_which.side_effect = mock_which
patched_subprocess_run.side_effect = mock_run
# Execute
diag = DiagReport()
result = diag._get_rpm_browser_versions()
# Verify
self.assertIsInstance(result, dict)
self.assertEqual(result['chromium'], '115.0.0')
@patch('subprocess.run')
def test_get_rpm_browser_versions__no_pkg_mgr(self, patched_subprocess_run):
# Setup - mock neither dnf nor yum available
patched_subprocess_run.side_effect = FileNotFoundError()
# Execute
diag = DiagReport()
result = diag._get_rpm_browser_versions()
# Verify
self.assertEqual(result, {})
@patch('subprocess.run')
@patch('shutil.which')
def test_get_pacman_browser_versions__success(self, patched_shutil_which, patched_subprocess_run):
# Setup - mock pacman available and finding firefox
def mock_run(cmd, *args, **kwargs):
# sourcery skip: no-conditionals-in-tests
if cmd[0] == 'pacman' and cmd[1] == '-Q' and 'firefox' in cmd:
return Mock(returncode=0, stdout=b'firefox 115.0.1-1')
return Mock(returncode=1, stdout=b'')
patched_subprocess_run.side_effect = mock_run
patched_shutil_which.return_value = '/path/to/command'
# Execute
diag = DiagReport()
result = diag._get_pacman_browser_versions()
# Verify
self.assertIsInstance(result, dict)
self.assertEqual(result['firefox'], '115.0.1-1')
@patch('subprocess.run')
def test_get_pacman_browser_versions__not_found(self, patched_subprocess_run):
# Setup - mock pacman not available
patched_subprocess_run.side_effect = FileNotFoundError()
# Execute
diag = DiagReport()
result = diag._get_pacman_browser_versions()
# Verify
self.assertEqual(result, {})
if __name__ == '__main__':
unittest.main()

View file

@ -2,7 +2,7 @@
import unittest
from unittest.mock import Mock, patch
from keyman_config.ibus_util import _get_ibus_version, get_ibus_bus, install_to_ibus, uninstall_from_ibus
from keyman_config.ibus_util import get_ibus_version, get_ibus_bus, install_to_ibus, uninstall_from_ibus
@patch('keyman_config.ibus_util.IBus.Bus')
@ -100,7 +100,7 @@ class IbusUtilTests(unittest.TestCase):
)
# Execute
ibus_version = _get_ibus_version()
ibus_version = get_ibus_version()
# Verify
self.assertEqual(ibus_version, '1.5.28')