mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-05 00:15:32 +00:00
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:
commit
bf388295ca
12 changed files with 1326 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
533
linux/keyman-config/keyman_config/diag_report.py
Normal file
533
linux/keyman-config/keyman_config/diag_report.py
Normal 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()
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
182
linux/keyman-config/keyman_config/support_widget.py
Normal file
182
linux/keyman-config/keyman_config/support_widget.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
39
linux/keyman-config/km-diag-report
Executable file
39
linux/keyman-config/km-diag-report
Executable 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()
|
||||
18
linux/keyman-config/km-diag-report.bash-completion
Normal file
18
linux/keyman-config/km-diag-report.bash-completion
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
517
linux/keyman-config/tests/diag_report_tests.py
Normal file
517
linux/keyman-config/tests/diag_report_tests.py
Normal 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()
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue