2192 lines
78 KiB
Python
2192 lines
78 KiB
Python
"""Abstract Apport user interface.
|
|
|
|
This encapsulates the workflow and common code for any user interface
|
|
implementation (like GTK, Qt, or CLI).
|
|
"""
|
|
|
|
# Copyright (C) 2007 - 2011 Canonical Ltd.
|
|
# Author: Martin Pitt <martin.pitt@ubuntu.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify it
|
|
# under the terms of the GNU General Public License as published by the
|
|
# Free Software Foundation; either version 2 of the License, or (at your
|
|
# option) any later version. See http://www.gnu.org/copyleft/gpl.html for
|
|
# the full text of the license.
|
|
|
|
# pylint: disable=too-many-lines
|
|
# TODO: Address following pylint complaints
|
|
# pylint: disable=invalid-name
|
|
|
|
import argparse
|
|
import ast
|
|
import configparser
|
|
import dataclasses
|
|
import errno
|
|
import gettext
|
|
import glob
|
|
import gzip
|
|
import io
|
|
import locale
|
|
import os.path
|
|
import pwd
|
|
import queue
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import smtplib
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.error
|
|
import webbrowser
|
|
import zlib
|
|
from collections.abc import Sequence
|
|
from gettext import gettext as _
|
|
from typing import Any
|
|
|
|
import apport.crashdb
|
|
import apport.fileutils
|
|
import apport.logging
|
|
import apport.REThread
|
|
from apport.packaging_impl import impl as packaging
|
|
from apport.user_group import get_process_user_and_group
|
|
|
|
__version__ = "2.28.1"
|
|
|
|
|
|
symptom_script_dir = os.environ.get("APPORT_SYMPTOMS_DIR", "/usr/share/apport/symptoms")
|
|
PF_KTHREAD = 0x200000
|
|
|
|
|
|
def get_pid(report):
|
|
"""Extract process ID from report."""
|
|
try:
|
|
pid = re.search("Pid:\t(.*)\n", report.get("ProcStatus", "")).group(1)
|
|
return int(pid)
|
|
except (IndexError, AttributeError):
|
|
return None
|
|
|
|
|
|
def _get_env_int(key: str, default: int | None = None) -> int | None:
|
|
"""Get an environment variable as integer.
|
|
|
|
Return None if it doesn't exist or failed to convert to integer.
|
|
The optional second argument can specify an alternate default.
|
|
"""
|
|
try:
|
|
return int(os.environ[key])
|
|
except (KeyError, ValueError):
|
|
return default
|
|
|
|
|
|
def _get_newest_process_for_user(name: str, uid: int) -> int | None:
|
|
process = subprocess.run(
|
|
["pgrep", "-n", "-x", "-u", str(uid), name],
|
|
capture_output=True,
|
|
check=False,
|
|
text=True,
|
|
)
|
|
if process.returncode != 0 or not process.stdout:
|
|
return None
|
|
return int(process.stdout.strip())
|
|
|
|
|
|
def _get_users_environ(uid: int) -> dict[str, str]:
|
|
"""Find D-BUS address and XDG_DATA_DIRS for the given user.
|
|
|
|
The D-BUS address and XDG_DATA_DIRS is needed for xdg-open. It is
|
|
incredibly hard, or alternatively, unsafe to funnel it through
|
|
pkexec/env/sudo, so grab it from gvfsd.
|
|
"""
|
|
gvfsd_pid = _get_newest_process_for_user("gvfsd", uid)
|
|
if gvfsd_pid is None:
|
|
return {}
|
|
|
|
gvfsd_pid_fd = os.open(
|
|
f"/proc/{gvfsd_pid}", os.O_RDONLY | os.O_PATH | os.O_DIRECTORY
|
|
)
|
|
try:
|
|
gvfsd_env = apport.fileutils.get_process_environ(gvfsd_pid_fd)
|
|
except OSError:
|
|
return {}
|
|
finally:
|
|
os.close(gvfsd_pid_fd)
|
|
|
|
return {
|
|
key: gvfsd_env[key]
|
|
for key in ("DBUS_SESSION_BUS_ADDRESS", "XDG_DATA_DIRS")
|
|
if key in gvfsd_env
|
|
}
|
|
|
|
|
|
def has_display() -> bool:
|
|
"""Check if a display server is specified in the environment."""
|
|
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
|
|
|
|
def run_as_real_user(
|
|
args: list[str], *, get_user_env: bool = False, **kwargs: Any
|
|
) -> None:
|
|
"""Call subprocess.run as real user if called via sudo/pkexec.
|
|
|
|
If we are called through pkexec/sudo, determine the real user ID and
|
|
run the command with it to get the user's web browser settings.
|
|
If get_user_env is set to True, the D-BUS address and XDG_DATA_DIRS
|
|
is grabbed from a running gvfsd and added to the process environment.
|
|
"""
|
|
uid = _get_env_int("SUDO_UID", _get_env_int("PKEXEC_UID"))
|
|
if uid is None or not get_process_user_and_group().is_root():
|
|
subprocess.run(args, check=False, **kwargs)
|
|
return
|
|
|
|
pwuid = pwd.getpwuid(uid)
|
|
|
|
gid = _get_env_int("SUDO_GID")
|
|
if gid is None:
|
|
gid = pwuid.pw_gid
|
|
|
|
env = {
|
|
k: v
|
|
for k, v in os.environ.items()
|
|
if not k.startswith("SUDO_") and k != "PKEXEC_UID"
|
|
}
|
|
if get_user_env:
|
|
env |= _get_users_environ(uid)
|
|
env["HOME"] = pwuid.pw_dir
|
|
# mypy on Ubuntu 22.04 and 22.10 does not know user and groups
|
|
subprocess.run(
|
|
args,
|
|
check=False,
|
|
env=env,
|
|
user=uid, # type: ignore
|
|
group=gid,
|
|
extra_groups=os.getgrouplist(pwuid.pw_name, gid),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def still_running(pid):
|
|
"""Check if the process with the given ID is still running."""
|
|
try:
|
|
os.kill(int(pid), 0)
|
|
except OSError as error:
|
|
if error.errno == errno.ESRCH:
|
|
return False
|
|
return True
|
|
|
|
|
|
def thread_collect_info(
|
|
report, reportfile, package, ui, symptom_script=None, ignore_uninstalled=False
|
|
):
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-branches,too-many-statements
|
|
"""Collect information about report.
|
|
|
|
Encapsulate calls to add_*_info() and update given report, so that this
|
|
function is suitable for threading.
|
|
|
|
ui must be a HookUI instance, it gets passed to add_hooks_info().
|
|
|
|
If reportfile is not None, the file is written back with the new data.
|
|
|
|
If symptom_script is given, it will be run first (for run_symptom()).
|
|
"""
|
|
assert isinstance(ui, HookUI)
|
|
try:
|
|
report.add_gdb_info()
|
|
except OSError:
|
|
# it's okay if gdb is not installed on the client side; we'll get stack
|
|
# traces on retracing.
|
|
pass
|
|
report.add_os_info()
|
|
|
|
if symptom_script:
|
|
symb = {}
|
|
try:
|
|
with open(symptom_script, encoding="utf-8") as f:
|
|
# legacy, pylint: disable=exec-used
|
|
exec(compile(f.read(), symptom_script, "exec"), symb)
|
|
package = symb["run"](report, ui)
|
|
if not package:
|
|
apport.logging.error(
|
|
"symptom script %s did not determine the affected package",
|
|
symptom_script,
|
|
)
|
|
return
|
|
report["Symptom"] = os.path.splitext(os.path.basename(symptom_script))[0]
|
|
except StopIteration:
|
|
sys.exit(0)
|
|
except Exception: # pylint: disable=broad-except
|
|
apport.logging.error("symptom script %s crashed:", symptom_script)
|
|
traceback.print_exc()
|
|
sys.exit(0)
|
|
|
|
if not package:
|
|
if "ExecutablePath" in report:
|
|
package = apport.fileutils.find_file_package(report["ExecutablePath"])
|
|
else:
|
|
raise KeyError(
|
|
"called without a package, and report does not have ExecutablePath"
|
|
)
|
|
|
|
# check if the package name relates to an installed snap
|
|
snap = apport.fileutils.find_snap(package)
|
|
if snap:
|
|
report.add_snap_info(snap)
|
|
|
|
try:
|
|
report.add_package_info(package)
|
|
except ValueError:
|
|
# this happens if we are collecting information on an uninstalled
|
|
# package
|
|
|
|
# we found no package, but a snap, so lets continue
|
|
if not ignore_uninstalled and "Snap" not in report:
|
|
raise
|
|
except SystemError as error:
|
|
report["UnreportableReason"] = str(error)
|
|
|
|
if "UnreportableReason" not in report:
|
|
if report.add_hooks_info(ui):
|
|
sys.exit(0)
|
|
|
|
# check package origin; we do that after adding hooks, so that hooks
|
|
# have the chance to set a third-party CrashDB.
|
|
try:
|
|
if (
|
|
"CrashDB" not in report
|
|
and "APPORT_DISABLE_DISTRO_CHECK" not in os.environ
|
|
):
|
|
if "Package" not in report and "Snap" not in report:
|
|
report["UnreportableReason"] = _(
|
|
"This package does not seem to be installed correctly"
|
|
)
|
|
elif not packaging.is_distro_package(
|
|
report["Package"].split()[0]
|
|
) and not packaging.is_native_origin_package(
|
|
report["Package"].split()[0]
|
|
):
|
|
# TRANS: %s is the name of the operating system
|
|
report["UnreportableReason"] = (
|
|
_(
|
|
"This does not seem to be an official %s package."
|
|
" Please retry after updating the indexes of"
|
|
" available packages, if that does not work"
|
|
" then remove related third party packages"
|
|
" and try again."
|
|
)
|
|
% report["DistroRelease"].split()[0]
|
|
)
|
|
except ValueError:
|
|
# this happens if we are collecting information on an uninstalled
|
|
# package
|
|
|
|
# we found no package, but a snap, so lets continue
|
|
if not ignore_uninstalled and "Snap" not in report:
|
|
raise
|
|
|
|
# add title
|
|
if "Title" not in report:
|
|
title = report.standard_title()
|
|
if title:
|
|
report["Title"] = title
|
|
|
|
# check obsolete packages
|
|
if (
|
|
report.get("ProblemType") == "Crash"
|
|
and "APPORT_IGNORE_OBSOLETE_PACKAGES" not in os.environ
|
|
):
|
|
old_pkgs = report.obsolete_packages()
|
|
if old_pkgs:
|
|
report["UnreportableReason"] = _(
|
|
"You have some obsolete package versions installed."
|
|
" Please upgrade the following packages"
|
|
" and check if the problem still occurs:\n\n%s"
|
|
) % ", ".join(old_pkgs)
|
|
|
|
if reportfile:
|
|
try:
|
|
with open(reportfile, "ab") as f:
|
|
os.chmod(reportfile, 0)
|
|
report.write(f, only_new=True)
|
|
except OSError as error:
|
|
# this should happen very rarely; presumably a new crash report is
|
|
# being generated by a background apport instance (which will set
|
|
# the file to permissions zero while writing), while the first
|
|
# report is being processed
|
|
apport.logging.error("Cannot update %s: %s", reportfile, error)
|
|
|
|
apport.fileutils.mark_report_seen(reportfile)
|
|
os.chmod(reportfile, 0o640)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class Action:
|
|
"""Action to take on a problem report.
|
|
|
|
Possible actions: examine the crash ('examine'), report the crash
|
|
('report'), restart the crashed application ('restart'), or ignore further
|
|
crashes ('ignore').
|
|
"""
|
|
|
|
examine: bool = False
|
|
ignore: bool = False
|
|
remember: bool = False
|
|
report: bool = False
|
|
restart: bool = False
|
|
|
|
|
|
class UserInterface:
|
|
# TODO: Check if some methods can be made private
|
|
# pylint: disable=too-many-public-methods
|
|
"""Apport user interface API.
|
|
|
|
This provides an abstract base class for encapsulating the workflow and
|
|
common code for any user interface implementation (like GTK, Qt, or CLI).
|
|
|
|
A concrete subclass must implement all the abstract ui_* methods.
|
|
"""
|
|
|
|
def __init__(self, argv: list[str]):
|
|
"""Initialize program state and parse command line options."""
|
|
self.gettext_domain = "apport"
|
|
self.report: apport.report.Report | None = None
|
|
self.report_file: str | None = None
|
|
self.cur_package = None
|
|
self.offer_restart = False
|
|
self.specified_a_pkg = False
|
|
self.upload_progress = None
|
|
|
|
try:
|
|
self.crashdb = apport.crashdb.get_crashdb(None)
|
|
except ImportError as error:
|
|
# this can happen while upgrading python packages
|
|
apport.logging.fatal(
|
|
"Could not import module, is a package upgrade in progress?"
|
|
" Error: %s",
|
|
str(error),
|
|
)
|
|
except KeyError:
|
|
apport.logging.fatal(
|
|
"/etc/apport/crashdb.conf is damaged: No default database"
|
|
)
|
|
|
|
gettext.textdomain(self.gettext_domain)
|
|
self.args = self.parse_argv(argv)
|
|
|
|
#
|
|
# main entry points
|
|
#
|
|
|
|
def run_crashes(self):
|
|
"""Present all currently pending crash reports.
|
|
|
|
Ask the user what to do about them, and offer to file bugs for them.
|
|
|
|
Crashes that occurred in a different desktop (logind) session than the
|
|
one that is currently running are not processed. This skips crashes
|
|
that happened during logout, which are uninteresting and confusing to
|
|
see at the next login.
|
|
|
|
Return True if at least one crash report was processed, False
|
|
otherwise.
|
|
"""
|
|
result = False
|
|
# for iterating over /var/crash (as opposed to running on or clicking
|
|
# on a particular .crash file) we offer restarting
|
|
self.offer_restart = True
|
|
|
|
if os.geteuid() == 0:
|
|
reports = apport.fileutils.get_new_system_reports()
|
|
else:
|
|
reports = apport.fileutils.get_new_reports()
|
|
|
|
for f in reports:
|
|
if not self.load_report(f):
|
|
continue
|
|
assert self.report
|
|
|
|
if self.report["ProblemType"] == "Hang":
|
|
self.finish_hang(f)
|
|
else:
|
|
self.run_crash(f)
|
|
result = True
|
|
|
|
return result
|
|
|
|
def run_crash(self, report_file):
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-branches,too-many-return-statements
|
|
# pylint: disable=too-many-statements
|
|
"""Present and report a particular crash.
|
|
|
|
If confirm is True, ask the user what to do about it, and offer to file
|
|
a bug for it.
|
|
|
|
If confirm is False, the user will not be asked, and the crash is
|
|
reported right away.
|
|
"""
|
|
self.report_file = report_file
|
|
|
|
try:
|
|
try:
|
|
apport.fileutils.mark_report_seen(report_file)
|
|
except OSError:
|
|
# not there any more? no problem, then it won't be regarded as
|
|
# "seen" any more anyway
|
|
pass
|
|
if not self.report and not self.load_report(report_file):
|
|
return
|
|
assert self.report
|
|
|
|
if "Ignore" in self.report:
|
|
return
|
|
|
|
# check for absent CoreDumps (removed if they exceed size limit)
|
|
if (
|
|
self.report.get("ProblemType") == "Crash"
|
|
and "Signal" in self.report
|
|
and "CoreDump" not in self.report
|
|
and "Stacktrace" not in self.report
|
|
):
|
|
subject = os.path.basename(
|
|
self.report.get("ExecutablePath", _("unknown program"))
|
|
)
|
|
heading = _('Sorry, the program "%s" closed unexpectedly') % subject
|
|
footer = _(
|
|
"Your computer does not have enough free "
|
|
"memory to automatically analyze the problem "
|
|
"and send a report to the developers."
|
|
)
|
|
self.ui_error_message(
|
|
_("Problem in %s") % subject, f"{heading}\n\n{footer}"
|
|
)
|
|
return
|
|
|
|
allowed_to_report = apport.fileutils.allowed_to_report()
|
|
response = self.ui_present_report_details(allowed_to_report)
|
|
if response.report or response.examine:
|
|
if "_MarkForUpload" not in self.report:
|
|
self.collect_info()
|
|
|
|
if self.report is None:
|
|
# collect() does that on invalid reports
|
|
return
|
|
|
|
if response.examine:
|
|
self.examine()
|
|
return
|
|
if response.restart:
|
|
self.restart()
|
|
if response.ignore:
|
|
self.report.mark_ignore()
|
|
try:
|
|
if response.remember:
|
|
self.remember_send_report(response.report)
|
|
# use try/expect for python2 support. Old reports (generated
|
|
# pre-apport 2.20.10-0ubuntu4) may not have the remember key
|
|
# and can be loaded afterwards (or after dist-upgrade)
|
|
except KeyError:
|
|
pass
|
|
if not response.report:
|
|
return
|
|
|
|
# We don't want to send crashes to the crash database for binaries
|
|
# that changed since the crash happened. See LP: #1039220 for
|
|
# details.
|
|
if (
|
|
"_MarkForUpload" in self.report
|
|
and self.report["_MarkForUpload"] != "False"
|
|
):
|
|
apport.fileutils.mark_report_upload(report_file)
|
|
# We check for duplicates and unreportable crashes here, rather
|
|
# than before we show the dialog, as we want to submit these to the
|
|
# crash database, but not Launchpad.
|
|
if self.crashdb.accepts(self.report):
|
|
# FIXME: This behaviour is not really correct, but necessary as
|
|
# long as we only support a single crashdb and have whoopsie
|
|
# hardcoded. Once we have multiple crash dbs, we need to check
|
|
# accepts() earlier, and not even present the data if none of
|
|
# the DBs wants the report. See LP#957177 for details.
|
|
if self.handle_duplicate():
|
|
return
|
|
if self.check_unreportable():
|
|
return
|
|
self.file_report()
|
|
except PermissionError:
|
|
self.ui_error_message(
|
|
_("Invalid problem report"),
|
|
_("You are not allowed to access this problem report."),
|
|
)
|
|
sys.exit(1)
|
|
except OSError as error:
|
|
if error.errno == errno.ENOMEM:
|
|
apport.logging.fatal("Out of memory, aborting")
|
|
elif error.errno == errno.ENOSPC:
|
|
self.ui_error_message(
|
|
_("Error"),
|
|
_(
|
|
"There is not enough disk space"
|
|
" available to process this report."
|
|
),
|
|
)
|
|
sys.exit(1)
|
|
elif error.errno == errno.EIO:
|
|
self.ui_error_message(_("Invalid problem report"), error.strerror)
|
|
sys.exit(1)
|
|
raise
|
|
|
|
@staticmethod
|
|
def finish_hang(f):
|
|
"""Finish processing a hanging application after the core pipe handler
|
|
has handed the report back.
|
|
|
|
This will signal to whoopsie that the report needs to be uploaded.
|
|
"""
|
|
apport.fileutils.mark_report_upload(f)
|
|
apport.fileutils.mark_report_seen(f)
|
|
|
|
def run_hang(self, pid):
|
|
"""Report an application hanging.
|
|
|
|
This will first present a dialog containing the information it can
|
|
collect from the running application (everything but the trace) with
|
|
the option of terminating or restarting the application, optionally
|
|
reporting that this error occurred.
|
|
|
|
A SIGABRT will then be sent to the process and a series of
|
|
noninteractive processes will collect the remaining information and
|
|
mark the report for uploading.
|
|
"""
|
|
self.report = apport.Report("Hang")
|
|
|
|
if not self.args.pid:
|
|
self.ui_error_message(
|
|
_("No PID specified"),
|
|
_("You need to specify a PID. See --help for more information."),
|
|
)
|
|
return False
|
|
|
|
try:
|
|
self.report.add_proc_info(pid)
|
|
except ValueError as error:
|
|
if str(error) == "invalid process":
|
|
self.ui_error_message(
|
|
_("Invalid PID"), _("The specified process ID does not exist.")
|
|
)
|
|
sys.exit(1)
|
|
elif str(error) == "not accessible":
|
|
self.ui_error_message(
|
|
_("Not your PID"),
|
|
_("The specified process ID does not belong to you."),
|
|
)
|
|
sys.exit(1)
|
|
self.report.add_package_info()
|
|
path = self.report.get("ExecutablePath", "")
|
|
self.cur_package = apport.fileutils.find_file_package(path)
|
|
self.report.add_os_info()
|
|
allowed_to_report = apport.fileutils.allowed_to_report()
|
|
response = self.ui_present_report_details(allowed_to_report, modal_for=int(pid))
|
|
if response.report:
|
|
apport.fileutils.mark_hanging_process(self.report, pid)
|
|
os.kill(int(pid), signal.SIGABRT)
|
|
else:
|
|
os.kill(int(pid), signal.SIGKILL)
|
|
|
|
if response.restart:
|
|
self.wait_for_pid(pid)
|
|
self.restart()
|
|
return True
|
|
|
|
@staticmethod
|
|
def wait_for_pid(pid):
|
|
"""waitpid() does not work for non-child processes. Query the process
|
|
state in a loop, waiting for "no such process."
|
|
"""
|
|
while True:
|
|
try:
|
|
os.kill(int(pid), 0)
|
|
except OSError as error:
|
|
if error.errno == errno.ESRCH:
|
|
break
|
|
raise
|
|
time.sleep(1)
|
|
|
|
@staticmethod
|
|
def kill_segv(pid):
|
|
"""Kill process with signal SIGSEGV."""
|
|
os.kill(int(pid), signal.SIGSEGV)
|
|
|
|
def run_report_bug(self, symptom_script: str | None = None) -> bool:
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-branches,too-many-return-statements
|
|
# pylint: disable=too-many-statements
|
|
"""Report a bug.
|
|
|
|
If a pid is given on the command line, the report will contain runtime
|
|
debug information. Either a package or a pid must be specified; if none
|
|
is given, show a list of symptoms.
|
|
|
|
If a symptom script is given, this will be run first (used by
|
|
run_symptom()).
|
|
"""
|
|
if not self.args.package and not self.args.pid and not symptom_script:
|
|
if self.run_symptoms():
|
|
return True
|
|
|
|
self.ui_error_message(
|
|
_("No package specified"),
|
|
_(
|
|
"You need to specify a package or a PID."
|
|
" See --help for more information."
|
|
),
|
|
)
|
|
return False
|
|
|
|
self.report = apport.Report("Bug")
|
|
|
|
# if PID is given, add info
|
|
if self.args.pid:
|
|
try:
|
|
proc_pid_fd = os.open(
|
|
f"/proc/{self.args.pid}", os.O_RDONLY | os.O_PATH | os.O_DIRECTORY
|
|
)
|
|
stat_file = os.open("stat", os.O_RDONLY, dir_fd=proc_pid_fd)
|
|
with io.open(stat_file, encoding="utf-8") as f:
|
|
stat = f.read().split()
|
|
flags = int(stat[8])
|
|
if flags & PF_KTHREAD:
|
|
# this PID is a kernel thread
|
|
self.args.package = "linux"
|
|
else:
|
|
self.report.add_proc_info(
|
|
pid=self.args.pid, proc_pid_fd=proc_pid_fd
|
|
)
|
|
except PermissionError:
|
|
self.ui_error_message(
|
|
_("Permission denied"),
|
|
_(
|
|
"The specified process does not belong to you. Please"
|
|
" run this program as the process owner or as root."
|
|
),
|
|
)
|
|
return False
|
|
except (ValueError, OSError) as error:
|
|
if getattr(error, "errno", None) == errno.ENOENT:
|
|
# silently ignore nonexisting PIDs; the user must not
|
|
# close the application prematurely
|
|
return False
|
|
self.ui_error_message(
|
|
_("Invalid PID"),
|
|
_("The specified process ID does not belong to a program."),
|
|
)
|
|
return False
|
|
else:
|
|
self.report.add_proc_environ()
|
|
|
|
if self.args.package:
|
|
self.args.package = self.args.package.strip()
|
|
# "Do what I mean" for filing against "linux"
|
|
if self.args.package == "linux":
|
|
self.cur_package = packaging.get_kernel_package()
|
|
else:
|
|
self.cur_package = self.args.package
|
|
|
|
try:
|
|
self.collect_info(symptom_script)
|
|
except ValueError as error:
|
|
if "package" in str(error) and "does not exist" in str(error):
|
|
if not self.cur_package:
|
|
self.ui_error_message(
|
|
_("Invalid problem report"),
|
|
_("Symptom script %s did not determine an affected package")
|
|
% symptom_script,
|
|
)
|
|
else:
|
|
self.ui_error_message(
|
|
_("Invalid problem report"),
|
|
_("Package %s does not exist") % self.cur_package,
|
|
)
|
|
return False
|
|
raise
|
|
|
|
if self.check_unreportable():
|
|
return False
|
|
|
|
self.add_extra_tags()
|
|
|
|
if self.handle_duplicate():
|
|
return True
|
|
|
|
# not useful for bug reports, and has potentially sensitive information
|
|
try:
|
|
del self.report["ProcCmdline"]
|
|
except KeyError:
|
|
pass
|
|
|
|
if self.args.save:
|
|
try:
|
|
savefile = os.path.expanduser(self.args.save)
|
|
if savefile.endswith(".gz"):
|
|
with gzip.open(savefile, "wb") as f:
|
|
self.report.write(f)
|
|
else:
|
|
with open(os.path.expanduser(self.args.save), "wb") as f:
|
|
self.report.write(f)
|
|
except OSError as error:
|
|
self.ui_error_message(_("Cannot create report"), str(error))
|
|
else:
|
|
# show what's being sent
|
|
allowed_to_report = True
|
|
response = self.ui_present_report_details(allowed_to_report)
|
|
if response.report:
|
|
self.file_report()
|
|
|
|
return True
|
|
|
|
def run_update_report(self) -> bool:
|
|
"""Update an existing bug with locally collected information."""
|
|
# avoid irrelevant noise
|
|
if not self.crashdb.can_update(self.args.update_report):
|
|
self.ui_error_message(
|
|
_("Updating problem report"),
|
|
_(
|
|
"You are not the reporter or subscriber of this "
|
|
"problem report, or the report is a duplicate or already "
|
|
'closed.\n\nPlease create a new report using "apport-bug".'
|
|
),
|
|
)
|
|
return False
|
|
|
|
is_reporter = self.crashdb.is_reporter(self.args.update_report)
|
|
|
|
if not is_reporter:
|
|
r = self.ui_question_yesno(
|
|
_(
|
|
"You are not the reporter of this problem report. It is "
|
|
"much easier to mark a bug as a duplicate of another than "
|
|
"to move your comments and attachments to a new bug.\n\n"
|
|
"Subsequently, we recommend that you file a new bug "
|
|
'report using "apport-bug" and make a comment in this bug '
|
|
"about the one you file.\n\nDo you really want to proceed?"
|
|
)
|
|
)
|
|
if not r:
|
|
return False
|
|
|
|
# list of affected source packages
|
|
self.report = apport.Report("Bug")
|
|
if self.args.package:
|
|
pkgs = [self.args.package.strip()]
|
|
else:
|
|
pkgs = self.crashdb.get_affected_packages(self.args.update_report)
|
|
|
|
info_collected = False
|
|
for p in pkgs:
|
|
# print(f"Collecting apport information for source package {p}...")
|
|
self.cur_package = p
|
|
self.report["SourcePackage"] = p
|
|
self.report["Package"] = p # no way to find this out
|
|
|
|
# we either must have the package installed or a source package
|
|
# hook available to collect sensible information
|
|
try:
|
|
packaging.get_version(p)
|
|
except ValueError:
|
|
if not os.path.exists(
|
|
os.path.join(apport.report.PACKAGE_HOOK_DIR, f"source_{p}.py")
|
|
):
|
|
print(f"Package {p} not installed and no hook available, ignoring")
|
|
continue
|
|
self.collect_info(ignore_uninstalled=True)
|
|
info_collected = True
|
|
|
|
if not info_collected:
|
|
self.ui_info_message(
|
|
_("Updating problem report"), _("No additional information collected.")
|
|
)
|
|
return False
|
|
|
|
self.report.add_user_info()
|
|
self.report.add_proc_environ()
|
|
self.add_extra_tags()
|
|
|
|
# delete the uninteresting keys
|
|
del self.report["Date"]
|
|
try:
|
|
del self.report["SourcePackage"]
|
|
except KeyError:
|
|
pass
|
|
|
|
if len(self.report) == 0:
|
|
self.ui_info_message(
|
|
_("Updating problem report"), _("No additional information collected.")
|
|
)
|
|
return False
|
|
|
|
# show what's being sent
|
|
allowed_to_report = True
|
|
response = self.ui_present_report_details(allowed_to_report)
|
|
if response.report:
|
|
self.crashdb.update(
|
|
self.args.update_report,
|
|
self.report,
|
|
"apport information",
|
|
change_description=is_reporter,
|
|
attachment_comment="apport information",
|
|
)
|
|
return True
|
|
|
|
return False
|
|
|
|
def run_symptoms(self):
|
|
"""Report a bug from a list of available symptoms.
|
|
|
|
Return False if no symptoms are available.
|
|
"""
|
|
scripts = glob.glob(os.path.join(symptom_script_dir, "*.py"))
|
|
|
|
# symptoms contains a list of (symptom_description, symptom_name)
|
|
symptoms = []
|
|
for script in scripts:
|
|
# scripts with an underscore can be used for private libraries
|
|
if os.path.basename(script).startswith("_"):
|
|
continue
|
|
symb = {}
|
|
try:
|
|
with open(script, encoding="utf-8") as f:
|
|
# legacy, pylint: disable=exec-used
|
|
exec(compile(f.read(), script, "exec"), symb)
|
|
except Exception: # pylint: disable=broad-except
|
|
apport.logging.error("symptom script %s is invalid", script)
|
|
traceback.print_exc()
|
|
continue
|
|
if "run" not in symb:
|
|
apport.logging.error(
|
|
"symptom script %s does not define run() function", script
|
|
)
|
|
continue
|
|
symptom_name = os.path.splitext(os.path.basename(script))[0]
|
|
symptoms.append((symb.get("description", symptom_name), symptom_name))
|
|
|
|
if not symptoms:
|
|
return False
|
|
|
|
symptoms.sort()
|
|
symptoms.append(("Other problem", None))
|
|
|
|
ch = self.ui_question_choice(
|
|
_("What kind of problem do you want to report?"),
|
|
[description for description, name in symptoms],
|
|
False,
|
|
)
|
|
|
|
if ch is not None:
|
|
symptom = symptoms[ch[0]][1]
|
|
if symptom:
|
|
self.run_report_bug(os.path.join(symptom_script_dir, symptom + ".py"))
|
|
else:
|
|
return False
|
|
|
|
return True
|
|
|
|
def run_symptom(self):
|
|
"""Report a bug with a symptom script."""
|
|
script = os.path.join(symptom_script_dir, self.args.symptom + ".py")
|
|
if not os.path.exists(script):
|
|
self.ui_error_message(
|
|
_("Unknown symptom"),
|
|
_('The symptom "%s" is not known.') % self.args.symptom,
|
|
)
|
|
return
|
|
|
|
self.run_report_bug(script)
|
|
|
|
def run_argv(self):
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-return-statements
|
|
"""Call appropriate run_* method according to command line arguments.
|
|
|
|
Return True if at least one report has been processed, and False
|
|
otherwise.
|
|
"""
|
|
if self.args.symptom:
|
|
self.run_symptom()
|
|
return True
|
|
if self.args.hanging:
|
|
self.run_hang(self.args.pid)
|
|
return True
|
|
if self.args.filebug:
|
|
return self.run_report_bug()
|
|
if self.args.update_report is not None:
|
|
return self.run_update_report()
|
|
if self.args.version:
|
|
print(__version__)
|
|
return True
|
|
if self.args.crash_file:
|
|
try:
|
|
self.run_crash(self.args.crash_file)
|
|
except OSError as error:
|
|
self.ui_error_message(_("Invalid problem report"), str(error))
|
|
return True
|
|
if self.args.window:
|
|
if os.getenv("XDG_SESSION_TYPE") == "wayland":
|
|
self.ui_error_message(
|
|
_("Cannot create report"),
|
|
_(
|
|
"The window option cannot be used on "
|
|
"Wayland.\n\nPlease find the window's "
|
|
"process ID and then run 'ubuntu-bug "
|
|
"<process ID>'."
|
|
"\n\nThe process ID can be found "
|
|
"by running the System Monitor application. "
|
|
"In the Processes tab, scroll until you "
|
|
"find the correct application. The process "
|
|
"ID is the number listed in the ID column."
|
|
),
|
|
)
|
|
return True
|
|
|
|
self.ui_info_message(
|
|
"",
|
|
_(
|
|
"After closing this message please click on an"
|
|
" application window to report a problem about it."
|
|
),
|
|
)
|
|
xprop = subprocess.run(
|
|
["xprop", "_NET_WM_PID"],
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
if xprop.returncode == 0:
|
|
try:
|
|
self.args.pid = int(xprop.stdout.split()[-1])
|
|
except ValueError:
|
|
self.ui_error_message(
|
|
_("Cannot create report"),
|
|
_("xprop failed to determine process ID of the window"),
|
|
)
|
|
return True
|
|
return self.run_report_bug()
|
|
|
|
self.ui_error_message(
|
|
_("Cannot create report"),
|
|
_("xprop failed to determine process ID of the window")
|
|
+ "\n\n"
|
|
+ xprop.stderr.decode(),
|
|
)
|
|
return True
|
|
|
|
return self.run_crashes()
|
|
|
|
#
|
|
# methods that implement workflow bits
|
|
#
|
|
|
|
@staticmethod
|
|
def parse_argv_update(argv: Sequence[str]) -> argparse.Namespace:
|
|
"""Parse command line options when being invoked in update mode."""
|
|
parser = argparse.ArgumentParser(usage=_("%(prog)s <report number>"))
|
|
parser.add_argument("-p", "--package", help=_("Specify package name."))
|
|
parser.add_argument(
|
|
"--tag",
|
|
action="append",
|
|
default=[],
|
|
dest="tags",
|
|
help=_("Add an extra tag to the report. Can be specified multiple times."),
|
|
)
|
|
parser.add_argument("update_report", metavar="report_number", type=int)
|
|
args = parser.parse_args(argv[1:])
|
|
|
|
args.symptom = None
|
|
args.filebug = False
|
|
args.crash_file = None
|
|
args.version = False
|
|
args.hanging = False
|
|
return args
|
|
|
|
def parse_argv(self, argv: list[str]) -> argparse.Namespace:
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-branches,too-many-statements
|
|
"""Parse command line options.
|
|
|
|
If a single argument is given without any options, this tries to "do
|
|
what I mean".
|
|
"""
|
|
# invoked in update mode?
|
|
if len(argv) > 0:
|
|
if "APPORT_INVOKED_AS" in os.environ:
|
|
argv[0] = os.path.join(
|
|
os.path.dirname(argv[0]),
|
|
os.path.basename(os.environ["APPORT_INVOKED_AS"]),
|
|
)
|
|
cmd = argv[0]
|
|
if cmd.endswith("-update-bug") or cmd.endswith("-collect"):
|
|
return self.parse_argv_update(argv)
|
|
|
|
if len(argv) > 0 and cmd.endswith("-bug"):
|
|
suppress = argparse.SUPPRESS
|
|
else:
|
|
suppress = None
|
|
|
|
parser = argparse.ArgumentParser(
|
|
usage=_(
|
|
"%(prog)s [options]"
|
|
" [symptom|pid|package|program path|.apport/.crash file]"
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
"--file-bug",
|
|
action="store_true",
|
|
dest="filebug",
|
|
help=suppress
|
|
or _(
|
|
"Start in bug filing mode. Requires --package and an optional"
|
|
" --pid, or just a --pid. If neither is given, display a list"
|
|
" of known symptoms. (Implied if a single argument is given.)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-w",
|
|
"--window",
|
|
action="store_true",
|
|
help=_("Click a window as a target for filing a problem report."),
|
|
)
|
|
parser.add_argument(
|
|
"-u",
|
|
"--update-bug",
|
|
type=int,
|
|
dest="update_report",
|
|
help=suppress
|
|
or _("Start in bug updating mode. Can take an optional --package."),
|
|
)
|
|
parser.add_argument(
|
|
"-s",
|
|
"--symptom",
|
|
metavar="SYMPTOM",
|
|
help=suppress
|
|
or _(
|
|
"File a bug report about a symptom. (Implied if symptom name"
|
|
" is given as only argument.)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-p",
|
|
"--package",
|
|
help=suppress
|
|
or _(
|
|
"Specify package name in --file-bug mode. This is optional"
|
|
" if a --pid is specified. (Implied if package name"
|
|
" is given as only argument.)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"-P",
|
|
"--pid",
|
|
type=int,
|
|
help=suppress
|
|
or _(
|
|
"Specify a running program in --file-bug mode. If this is"
|
|
" specified, the bug report will contain more information."
|
|
" (Implied if pid is given as only argument.)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--hanging",
|
|
action="store_true",
|
|
help=_("The provided pid is a hanging application."),
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--crash-file",
|
|
metavar="PATH",
|
|
help=suppress
|
|
or _(
|
|
"Report the crash from given .apport or .crash file"
|
|
" instead of the pending ones in %s."
|
|
" (Implied if file is given as only argument.)"
|
|
)
|
|
% apport.fileutils.report_dir,
|
|
)
|
|
parser.add_argument(
|
|
"--save",
|
|
metavar="PATH",
|
|
help=_(
|
|
"In bug filing mode, save the collected information into"
|
|
" a file instead of reporting it. This file can then be"
|
|
" reported later on from a different machine."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--tag",
|
|
action="append",
|
|
default=[],
|
|
dest="tags",
|
|
help=_("Add an extra tag to the report. Can be specified multiple times."),
|
|
)
|
|
parser.add_argument(
|
|
"-v",
|
|
"--version",
|
|
action="store_true",
|
|
help=_("Print the Apport version number."),
|
|
)
|
|
parser.add_argument("issue", nargs="?", help=argparse.SUPPRESS)
|
|
|
|
args = parser.parse_args(argv[1:])
|
|
issue = args.issue
|
|
del args.issue
|
|
|
|
# mutually exclusive arguments
|
|
if args.update_report:
|
|
args_only_for_new_reports = [
|
|
args.filebug,
|
|
args.window,
|
|
args.symptom,
|
|
args.pid,
|
|
args.crash_file,
|
|
args.save,
|
|
]
|
|
if any(args_only_for_new_reports):
|
|
parser.error(
|
|
"-u/--update-bug option cannot be used together"
|
|
" with options for a new report"
|
|
)
|
|
|
|
# no argument: default to "show pending crashes" except when called in
|
|
# bug mode
|
|
# NOTE: uses argv, since args if empty for all the options,
|
|
# e.g. "-v" or "-u $BUG"
|
|
if len(argv) == 1 and cmd.endswith("-bug"):
|
|
args.filebug = True
|
|
return args
|
|
|
|
# one argument: guess "file bug" mode by argument type
|
|
if issue is None:
|
|
return args
|
|
|
|
# symptom?
|
|
if os.path.exists(os.path.join(symptom_script_dir, issue + ".py")):
|
|
args.filebug = True
|
|
args.symptom = issue
|
|
|
|
# .crash/.apport file?
|
|
elif issue.endswith(".crash") or issue.endswith(".apport"):
|
|
args.crash_file = issue
|
|
|
|
# PID?
|
|
elif issue.isdigit():
|
|
args.filebug = True
|
|
args.pid = issue
|
|
|
|
# executable?
|
|
elif "/" in issue:
|
|
if issue.startswith("/snap/bin"):
|
|
# see if the snap has the same name as the executable
|
|
snap = apport.fileutils.find_snap(issue.split("/")[-1])
|
|
if not snap:
|
|
parser.error(
|
|
f"{issue} is provided by a snap. No contact address"
|
|
f" has been provided; visit the forum at"
|
|
f" https://forum.snapcraft.io/ for help."
|
|
)
|
|
elif snap.get("contact", ""):
|
|
parser.error(
|
|
f"{issue} is provided by a snap published by"
|
|
f" {snap['developer']}. Contact them"
|
|
f" via {snap['contact']} for help."
|
|
)
|
|
else:
|
|
parser.error(
|
|
f"{issue} is provided by a snap published by"
|
|
f" {snap['developer']}. No contact address"
|
|
f" has been provided; visit the forum"
|
|
f" at https://forum.snapcraft.io/ for help."
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
pkg = packaging.get_file_package(issue)
|
|
if not pkg:
|
|
parser.error(f"{issue} does not belong to a package.")
|
|
sys.exit(1)
|
|
args.filebug = True
|
|
args.package = pkg
|
|
|
|
# otherwise: package name
|
|
else:
|
|
args.filebug = True
|
|
self.specified_a_pkg = True
|
|
args.package = issue
|
|
|
|
return args
|
|
|
|
@staticmethod
|
|
def format_filesize(size):
|
|
"""Format the given integer as humanly readable and i18n'ed file
|
|
size."""
|
|
if size < 1000000:
|
|
return locale.format_string("%.1f KB", size / 1000.0)
|
|
if size < 1000000000:
|
|
return locale.format_string("%.1f MB", size / 1000000.0)
|
|
return locale.format_string("%.1f GB", size / float(1000000000))
|
|
|
|
def get_complete_size(self):
|
|
"""Return the size of the complete report."""
|
|
assert self.report
|
|
# report wasn't loaded, so count manually
|
|
size = 0
|
|
for k in self.report:
|
|
if self.report[k]:
|
|
try:
|
|
size += self.report[k].get_on_disk_size()
|
|
except AttributeError:
|
|
size += len(self.report[k])
|
|
return size
|
|
|
|
def get_reduced_size(self):
|
|
"""Return the size of the reduced report."""
|
|
assert self.report
|
|
size = 0
|
|
for k in self.report:
|
|
if k != "CoreDump":
|
|
if self.report[k]:
|
|
try:
|
|
size += self.report[k].get_on_disk_size()
|
|
except AttributeError:
|
|
size += len(self.report[k])
|
|
|
|
return size
|
|
|
|
def can_examine_locally(self):
|
|
"""Check whether to offer the "Examine locally" button.
|
|
|
|
This will be true if the report has a core dump, apport-retrace is
|
|
installed and a terminal is available (see ui_has_terminal()).
|
|
"""
|
|
if not self.report or "CoreDump" not in self.report:
|
|
return False
|
|
|
|
if shutil.which("apport-retrace") is None:
|
|
return False
|
|
|
|
return self.ui_has_terminal()
|
|
|
|
def restart(self):
|
|
"""Reopen the crashed application."""
|
|
assert self.report and "ProcCmdline" in self.report
|
|
|
|
if os.fork() == 0:
|
|
os.setsid()
|
|
os.execlp(
|
|
"sh",
|
|
"sh",
|
|
"-c",
|
|
self.report.get("RespawnCommand", self.report["ProcCmdline"]),
|
|
)
|
|
sys.exit(1)
|
|
|
|
def examine(self):
|
|
"""Locally examine crash report."""
|
|
assert self.report_file
|
|
response = self.ui_question_choice(
|
|
_(
|
|
"This will launch apport-retrace in a terminal window"
|
|
" to examine the crash."
|
|
),
|
|
[
|
|
_("Run gdb session"),
|
|
_("Run gdb session without downloading debug symbols"),
|
|
# TRANSLATORS: %s contains the crash report file name
|
|
_("Update %s with fully symbolic stack trace") % self.report_file,
|
|
],
|
|
False,
|
|
)
|
|
|
|
if response is None:
|
|
return
|
|
|
|
cache_dir = os.path.expanduser("~/.cache/apport/retrace")
|
|
retrace_with_download = f"apport-retrace -S system -C {cache_dir} -v "
|
|
retrace_no_download = "apport-retrace "
|
|
filearg = "'" + self.report_file.replace("'", "'\\''") + "'"
|
|
|
|
cmds = {
|
|
0: retrace_with_download + "--gdb " + filearg,
|
|
1: retrace_no_download + "--gdb " + filearg,
|
|
2: retrace_with_download + "--output " + filearg + " " + filearg,
|
|
}
|
|
|
|
self.ui_run_terminal(cmds[response[0]])
|
|
|
|
def remember_send_report(self, send_report: bool) -> None:
|
|
"""Put whoopsie in auto or never mode."""
|
|
try:
|
|
subprocess.check_output(
|
|
[
|
|
"/usr/bin/gdbus",
|
|
"call",
|
|
"-y",
|
|
"-d",
|
|
"com.ubuntu.WhoopsiePreferences",
|
|
"-o",
|
|
"/com/ubuntu/WhoopsiePreferences",
|
|
"-m",
|
|
"com.ubuntu.WhoopsiePreferences.SetReportCrashes",
|
|
str(send_report).lower(),
|
|
]
|
|
)
|
|
subprocess.check_output(
|
|
[
|
|
"/usr/bin/gdbus",
|
|
"call",
|
|
"-y",
|
|
"-d",
|
|
"com.ubuntu.WhoopsiePreferences",
|
|
"-o",
|
|
"/com/ubuntu/WhoopsiePreferences",
|
|
"-m",
|
|
"com.ubuntu.WhoopsiePreferences.SetAutomaticallyReportCrashes",
|
|
"true",
|
|
]
|
|
)
|
|
except (OSError, subprocess.CalledProcessError) as error:
|
|
msg = _(
|
|
"Saving crash reporting state failed."
|
|
" Can't set auto or never reporting mode."
|
|
)
|
|
self.ui_error_message(
|
|
_("Can't remember send report status settings"),
|
|
f"{msg}\n\n{str(error)}",
|
|
)
|
|
|
|
def check_report_crashdb(self):
|
|
"""Process reports' CrashDB field, if present."""
|
|
if self.report is None or "CrashDB" not in self.report:
|
|
return True
|
|
|
|
# specification?
|
|
if self.report["CrashDB"].lstrip().startswith("{"):
|
|
try:
|
|
spec = ast.literal_eval(self.report["CrashDB"])
|
|
assert isinstance(spec, dict)
|
|
assert "impl" in spec
|
|
except (AssertionError, SyntaxError, ValueError) as error:
|
|
self.report["UnreportableReason"] = (
|
|
f"A package hook defines an invalid crash database"
|
|
f" definition:\n{self.report['CrashDB']}\n{error}"
|
|
)
|
|
return False
|
|
try:
|
|
self.crashdb = apport.crashdb.load_crashdb(None, spec)
|
|
except (ImportError, KeyError):
|
|
self.report["UnreportableReason"] = (
|
|
f"A package hook wants to send this report to the crash"
|
|
f' database "{self.report["CrashDB"]}"'
|
|
f" which does not exist."
|
|
)
|
|
|
|
else:
|
|
# DB name
|
|
try:
|
|
self.crashdb = apport.crashdb.get_crashdb(None, self.report["CrashDB"])
|
|
except (ImportError, KeyError):
|
|
self.report["UnreportableReason"] = (
|
|
f"A package hook wants to send this report to the crash"
|
|
f' database "{self.report["CrashDB"]}"'
|
|
f" which does not exist."
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
def collect_info(
|
|
self, symptom_script=None, ignore_uninstalled=False, on_finished=None
|
|
):
|
|
# TODO: Split into smaller functions/methods
|
|
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
|
|
"""Collect additional information.
|
|
|
|
Call all the add_*_info() methods and display a progress dialog during
|
|
this.
|
|
|
|
In particular, this adds OS, package and gdb information and checks bug
|
|
patterns.
|
|
|
|
If a symptom script is given, this will be run first (used by
|
|
run_symptom()).
|
|
"""
|
|
assert self.report
|
|
self.report["_MarkForUpload"] = "True"
|
|
|
|
# skip if we already ran (we might load a processed report)
|
|
if (
|
|
self.report.get("ProblemType") == "Crash" and "Stacktrace" in self.report
|
|
) or (
|
|
self.report.get("ProblemType") != "Crash" and "Dependencies" in self.report
|
|
):
|
|
if on_finished:
|
|
on_finished()
|
|
return
|
|
|
|
# ensure that the crashed program is still installed:
|
|
if self.report.get("ProblemType") == "Crash":
|
|
exe_path = self.report.get("ExecutablePath", "")
|
|
if not os.path.exists(exe_path):
|
|
msg = _(
|
|
"This problem report applies to a program"
|
|
" which is not installed any more."
|
|
)
|
|
if exe_path:
|
|
msg = f"{msg} ({self.report['ExecutablePath']})"
|
|
self.report["UnreportableReason"] = msg
|
|
if on_finished:
|
|
on_finished()
|
|
return
|
|
|
|
if "InterpreterPath" in self.report:
|
|
if not os.path.exists(self.report["InterpreterPath"]):
|
|
msg = _(
|
|
"This problem report applies to a program"
|
|
" which is not installed any more."
|
|
)
|
|
self.report["UnreportableReason"] = (
|
|
f"{msg} ({self.report['InterpreterPath']})"
|
|
)
|
|
if on_finished:
|
|
on_finished()
|
|
return
|
|
|
|
# check if binary changed since the crash happened
|
|
if "ExecutablePath" in self.report and "ExecutableTimestamp" in self.report:
|
|
orig_time = int(self.report["ExecutableTimestamp"])
|
|
del self.report["ExecutableTimestamp"]
|
|
cur_time = int(os.stat(self.report["ExecutablePath"]).st_mtime)
|
|
|
|
if orig_time != cur_time:
|
|
self.report["_MarkForUpload"] = "False"
|
|
self.report["UnreportableReason"] = (
|
|
_(
|
|
"The problem happened with the program %s"
|
|
" which changed since the crash occurred."
|
|
)
|
|
% self.report["ExecutablePath"]
|
|
)
|
|
return
|
|
|
|
if (
|
|
not self.cur_package
|
|
and "ExecutablePath" not in self.report
|
|
and not symptom_script
|
|
):
|
|
# this happens if we file a bug without specifying a PID or a
|
|
# package
|
|
self.report.add_os_info()
|
|
else:
|
|
# since this might take a while, create separate threads and
|
|
# display a progress dialog.
|
|
self.ui_start_info_collection_progress()
|
|
# only use a UI for asking questions if the crash db will accept
|
|
# the report
|
|
if self.crashdb.accepts(self.report):
|
|
hookui = HookUI(self)
|
|
else:
|
|
hookui = NoninteractiveHookUI()
|
|
|
|
if "Stacktrace" not in self.report:
|
|
# save original environment, in case hooks change it
|
|
orig_env = os.environ.copy()
|
|
icthread = apport.REThread.REThread(
|
|
target=thread_collect_info,
|
|
name="thread_collect_info",
|
|
args=(
|
|
self.report,
|
|
self.report_file,
|
|
self.cur_package,
|
|
hookui,
|
|
symptom_script,
|
|
ignore_uninstalled,
|
|
),
|
|
)
|
|
icthread.start()
|
|
while icthread.is_alive():
|
|
self.ui_pulse_info_collection_progress()
|
|
try:
|
|
hookui.process_event()
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
|
|
icthread.join()
|
|
|
|
# restore original environment
|
|
os.environ.clear()
|
|
os.environ.update(orig_env)
|
|
|
|
try:
|
|
icthread.exc_raise()
|
|
except (OSError, EOFError, zlib.error) as error:
|
|
# can happen with broken core dumps
|
|
msg = _("This problem report is damaged and cannot be processed.")
|
|
self.report["UnreportableReason"] = f"{msg}\n\n{repr(error)}"
|
|
self.report["_MarkForUpload"] = "False"
|
|
except ValueError: # package does not exist
|
|
if "UnreportableReason" not in self.report:
|
|
self.report["UnreportableReason"] = _(
|
|
"This report is about a package that is not installed."
|
|
)
|
|
self.report["_MarkForUpload"] = "False"
|
|
except Exception as error: # pylint: disable=broad-except
|
|
apport.logging.error("%s", repr(error))
|
|
self.report["UnreportableReason"] = (
|
|
_(
|
|
"An error occurred while attempting to "
|
|
"process this problem report:"
|
|
)
|
|
+ "\n\n"
|
|
+ str(error)
|
|
)
|
|
self.report["_MarkForUpload"] = "False"
|
|
|
|
# ask for target, if snap and deb package are installed
|
|
if "Snap" in self.report and "Package" in self.report:
|
|
if "(not installed)" in self.report["Package"]:
|
|
# choose snap automatically, if deb package
|
|
# is not installed
|
|
res = [0]
|
|
else:
|
|
res = self.ui_question_choice(
|
|
_(
|
|
"You have two versions of this application"
|
|
" installed, which one do you want to report"
|
|
" a bug against?"
|
|
),
|
|
[
|
|
_("%s snap") % self.report["Snap"],
|
|
_("%s deb package") % self.report["Package"],
|
|
],
|
|
False,
|
|
)
|
|
# bug report is about the snap, clean deb package info
|
|
if res == [0]:
|
|
del self.report["Package"]
|
|
if "PackageArchitecture" in self.report:
|
|
del self.report["PackageArchitecture"]
|
|
if "SourcePackage" in self.report:
|
|
del self.report["SourcePackage"]
|
|
# bug report is about the deb package, clean snap info
|
|
elif res == [1]:
|
|
del self.report["Snap"]
|
|
if "SnapSource" in self.report:
|
|
del self.report["SnapSource"]
|
|
if "SnapTags" in self.report:
|
|
del self.report["SnapTags"]
|
|
else:
|
|
self.ui_stop_info_collection_progress()
|
|
sys.exit(0)
|
|
|
|
# append snap tags, if this report is about the snap
|
|
if "Snap" in self.report and "SnapTags" in self.report:
|
|
self.report.add_tags(self.report.pop("SnapTags").split(" "))
|
|
|
|
# show a hint if we cannot auto report a snap bug via 'SnapSource'
|
|
if (
|
|
"Snap" in self.report
|
|
and (
|
|
"SnapSource" not in self.report and "SnapGitName" not in self.report
|
|
)
|
|
and "UnreportableReason" not in self.report
|
|
and self.specified_a_pkg
|
|
):
|
|
snap = apport.fileutils.find_snap(self.cur_package)
|
|
if snap.get("contact", ""):
|
|
self.report["UnreportableReason"] = _(
|
|
"%s is provided by a snap published by %s."
|
|
" Contact them via %s for help."
|
|
) % (snap["name"], snap["developer"], snap["contact"])
|
|
else:
|
|
self.report["UnreportableReason"] = _(
|
|
"%s is provided by a snap published by %s. No contact"
|
|
" address has been provided; visit the forum"
|
|
" at https://forum.snapcraft.io/ for help."
|
|
) % (snap["name"], snap["developer"])
|
|
self.report["_MarkForUpload"] = "False"
|
|
|
|
if "UnreportableReason" in self.report or not self.check_report_crashdb():
|
|
self.ui_stop_info_collection_progress()
|
|
if on_finished:
|
|
on_finished()
|
|
return
|
|
|
|
# check bug patterns
|
|
if (
|
|
self.report.get("ProblemType") == "KernelCrash"
|
|
or self.report.get("ProblemType") == "KernelOops"
|
|
or "Package" in self.report
|
|
):
|
|
bpthread = apport.REThread.REThread(
|
|
target=self.report.search_bug_patterns,
|
|
args=(self.crashdb.get_bugpattern_baseurl(),),
|
|
)
|
|
bpthread.start()
|
|
while bpthread.is_alive():
|
|
self.ui_pulse_info_collection_progress()
|
|
try:
|
|
bpthread.join(0.1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
try:
|
|
bpthread.exc_raise()
|
|
except (OSError, EOFError, zlib.error) as error:
|
|
# can happen with broken gz values
|
|
msg = _("This problem report is damaged and cannot be processed.")
|
|
self.report["UnreportableReason"] = f"{msg}\n\n{repr(error)}"
|
|
if bpthread.return_value():
|
|
self.report["_KnownReport"] = bpthread.return_value()
|
|
|
|
# check crash database if problem is known
|
|
if self.report.get("ProblemType") != "Bug":
|
|
known_thread = apport.REThread.REThread(
|
|
target=self.crashdb.known, args=(self.report,)
|
|
)
|
|
known_thread.start()
|
|
while known_thread.is_alive():
|
|
self.ui_pulse_info_collection_progress()
|
|
try:
|
|
known_thread.join(0.1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
known_thread.exc_raise()
|
|
val = known_thread.return_value()
|
|
if val is not None:
|
|
if val is True:
|
|
self.report["_KnownReport"] = "1"
|
|
else:
|
|
self.report["_KnownReport"] = val
|
|
|
|
# anonymize; needs to happen after duplicate checking, otherwise we
|
|
# might damage the stack trace
|
|
anonymize_thread = apport.REThread.REThread(target=self.report.anonymize)
|
|
anonymize_thread.start()
|
|
while anonymize_thread.is_alive():
|
|
self.ui_pulse_info_collection_progress()
|
|
try:
|
|
anonymize_thread.join(0.1)
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
anonymize_thread.exc_raise()
|
|
|
|
self.ui_stop_info_collection_progress()
|
|
|
|
# check that we were able to determine package names
|
|
if "UnreportableReason" not in self.report:
|
|
if (
|
|
(
|
|
"SourcePackage" not in self.report
|
|
and "Dependencies" not in self.report
|
|
)
|
|
or (
|
|
not self.report.get("ProblemType", "").startswith("Kernel")
|
|
and "Package" not in self.report
|
|
)
|
|
) and not self._is_snap():
|
|
self.ui_error_message(
|
|
_("Invalid problem report"),
|
|
_("Could not determine the package or source package name."),
|
|
)
|
|
# TODO This is not called consistently,
|
|
# is it really needed?
|
|
self.ui_shutdown()
|
|
sys.exit(1)
|
|
|
|
if on_finished:
|
|
on_finished()
|
|
|
|
def _is_snap(self):
|
|
assert self.report
|
|
return "SnapSource" in self.report or "Snap" in self.report
|
|
|
|
def open_url(self, url):
|
|
"""Open the given URL in a new browser window.
|
|
|
|
Display an error dialog if everything fails.
|
|
"""
|
|
try:
|
|
try:
|
|
run_as_real_user(["xdg-open", url], get_user_env=True)
|
|
return
|
|
except OSError:
|
|
# fall back to webbrowser
|
|
if webbrowser.open(url, new=1, autoraise=True):
|
|
return
|
|
error_details = ""
|
|
except Exception as error: # pylint: disable=broad-except
|
|
error_details = f"\n{error}"
|
|
|
|
title = _("Unable to start web browser")
|
|
message = _("Unable to start web browser to open %s.") % url
|
|
self.ui_error_message(title, message + error_details)
|
|
|
|
def file_report(self):
|
|
"""Upload the current report and guide the user to the reporting
|
|
web page."""
|
|
assert self.report
|
|
# FIXME: This behaviour is not really correct, but necessary as
|
|
# long as we only support a single crashdb and have whoopsie
|
|
# hardcoded. Once we have multiple crash dbs, we need to check
|
|
# accepts() earlier, and not even present the data if none of
|
|
# the DBs wants the report. See LP#957177 for details.
|
|
if not self.crashdb.accepts(self.report):
|
|
return
|
|
# drop PackageArchitecture if equal to Architecture
|
|
if self.report.get("PackageArchitecture") == self.report.get("Architecture"):
|
|
try:
|
|
del self.report["PackageArchitecture"]
|
|
except KeyError:
|
|
pass
|
|
|
|
# StacktraceAddressSignature is redundant and does not need to clutter
|
|
# the database
|
|
try:
|
|
del self.report["StacktraceAddressSignature"]
|
|
except KeyError:
|
|
pass
|
|
|
|
self.upload_progress = None
|
|
|
|
def progress_callback(sent, total):
|
|
self.upload_progress = float(sent) / total
|
|
|
|
message_queue = queue.SimpleQueue()
|
|
|
|
def message_callback(title, text):
|
|
message_displayed = threading.Event()
|
|
message_queue.put((title, text, message_displayed))
|
|
message_displayed.wait()
|
|
|
|
# drop internal/uninteresting keys, that start with "_"
|
|
for k in list(self.report):
|
|
if k.startswith("_"):
|
|
del self.report[k]
|
|
|
|
self.ui_start_upload_progress()
|
|
upthread = apport.REThread.REThread(
|
|
target=self.crashdb.upload,
|
|
args=(self.report, progress_callback, message_callback),
|
|
)
|
|
upthread.start()
|
|
try:
|
|
while upthread.is_alive():
|
|
self.ui_set_upload_progress(self.upload_progress)
|
|
try:
|
|
title, text, msg_displayed = message_queue.get(
|
|
block=True, timeout=0.1
|
|
)
|
|
self.ui_info_message(title, text)
|
|
msg_displayed.set()
|
|
upthread.exc_raise()
|
|
except queue.Empty:
|
|
pass
|
|
|
|
upthread.exc_raise()
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
except (smtplib.SMTPConnectError, urllib.error.URLError) as error:
|
|
msg = _(
|
|
"Cannot connect to crash database,"
|
|
" please check your Internet connection."
|
|
)
|
|
self.ui_error_message(_("Network problem"), f"{msg}\n\n{str(error)}")
|
|
return
|
|
|
|
ticket = upthread.return_value()
|
|
self.ui_stop_upload_progress()
|
|
|
|
url = self.crashdb.get_comment_url(self.report, ticket)
|
|
if url:
|
|
self.open_url(url)
|
|
|
|
def load_report(self, path):
|
|
"""Load report from given path and do some consistency checks.
|
|
|
|
This might issue an error message and return False if the report cannot
|
|
be processed, otherwise self.report is initialized and True is
|
|
returned.
|
|
"""
|
|
try:
|
|
self.report = apport.Report()
|
|
with open(path, "rb") as f:
|
|
self.report.load(f, binary="compressed")
|
|
if "ProblemType" not in self.report:
|
|
raise ValueError('Report does not contain "ProblemType" field')
|
|
except MemoryError:
|
|
self.report = None
|
|
self.ui_error_message(
|
|
_("Memory exhaustion"),
|
|
_(
|
|
"Your system does not have enough memory"
|
|
" to process this crash report."
|
|
),
|
|
)
|
|
return False
|
|
except OSError as error:
|
|
self.report = None
|
|
self.ui_error_message(_("Invalid problem report"), error.strerror)
|
|
return False
|
|
except (TypeError, ValueError, AssertionError, zlib.error) as error:
|
|
self.report = None
|
|
msg = _("This problem report is damaged and cannot be processed.")
|
|
self.ui_error_message(
|
|
_("Invalid problem report"), f"{msg}\n\n{repr(error)}"
|
|
)
|
|
return False
|
|
|
|
if "Package" in self.report:
|
|
self.cur_package = self.report["Package"].split()[0]
|
|
else:
|
|
self.cur_package = apport.fileutils.find_file_package(
|
|
self.report.get("ExecutablePath", "")
|
|
)
|
|
|
|
return True
|
|
|
|
def check_unreportable(self):
|
|
"""Check if the current report is unreportable.
|
|
|
|
If so, display an info message and return True.
|
|
"""
|
|
assert self.report
|
|
if not self.crashdb.accepts(self.report):
|
|
return False
|
|
if "UnreportableReason" in self.report:
|
|
if isinstance(self.report["UnreportableReason"], bytes):
|
|
self.report["UnreportableReason"] = self.report[
|
|
"UnreportableReason"
|
|
].decode("UTF-8")
|
|
if "Package" in self.report:
|
|
title = _("Problem in %s") % self.report["Package"].split()[0]
|
|
else:
|
|
title = ""
|
|
self.ui_info_message(
|
|
title,
|
|
_("The problem cannot be reported:\n\n%s")
|
|
% self.report["UnreportableReason"],
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def get_desktop_entry(self):
|
|
"""Return a .desktop info dictionary for the current report.
|
|
|
|
Return None if report cannot be associated to a .desktop file.
|
|
"""
|
|
assert self.report
|
|
if "DesktopFile" in self.report and os.path.exists(self.report["DesktopFile"]):
|
|
desktop_file = self.report["DesktopFile"]
|
|
else:
|
|
try:
|
|
desktop_file = apport.fileutils.find_package_desktopfile(
|
|
self.cur_package
|
|
)
|
|
except ValueError:
|
|
return None
|
|
|
|
if not desktop_file:
|
|
return None
|
|
|
|
cp = configparser.ConfigParser(interpolation=None, strict=False)
|
|
try:
|
|
cp.read(desktop_file, encoding="UTF-8")
|
|
except configparser.Error as error:
|
|
sys.stderr.write(f"Warning! {desktop_file} is broken: {str(error)}\n")
|
|
return None
|
|
if not cp.has_section("Desktop Entry"):
|
|
return None
|
|
result = dict(cp.items("Desktop Entry"))
|
|
if "name" not in result:
|
|
return None
|
|
return result
|
|
|
|
def handle_duplicate(self):
|
|
"""Check if current report matches a bug pattern.
|
|
|
|
If so, tell the user about it, open the existing bug in a browser, and
|
|
return True.
|
|
"""
|
|
assert self.report
|
|
if not self.crashdb.accepts(self.report):
|
|
return False
|
|
if "_KnownReport" not in self.report:
|
|
return False
|
|
|
|
# if we have an URL, open it; otherwise this is just a marker that we
|
|
# know about it
|
|
if self.report["_KnownReport"].startswith("http"):
|
|
self.ui_info_message(
|
|
_("Problem already known"),
|
|
_(
|
|
"This problem was already reported in the bug report"
|
|
" displayed in the web browser. Please check"
|
|
" if you can add any further information"
|
|
" that might be helpful for the developers."
|
|
),
|
|
)
|
|
|
|
self.open_url(self.report["_KnownReport"])
|
|
else:
|
|
self.ui_info_message(
|
|
_("Problem already known"),
|
|
_("This problem was already reported to developers. Thank you!"),
|
|
)
|
|
|
|
return True
|
|
|
|
def add_extra_tags(self):
|
|
"""Add extra tags to report specified with --tags on CLI."""
|
|
assert self.report
|
|
if self.args.tags:
|
|
self.report.add_tags(self.args.tags)
|
|
|
|
#
|
|
# abstract UI methods that must be implemented in derived classes
|
|
#
|
|
|
|
def ui_present_report_details(
|
|
self, allowed_to_report: bool = True, modal_for: int | None = None
|
|
) -> Action:
|
|
"""Show details of the bug report.
|
|
|
|
Return the action and options as an Action object:
|
|
|
|
- Valid attributes are: report the crash ('report'), restart
|
|
the crashed application ('restart'), or ignore further crashes
|
|
('ignore').
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_info_message(self, title, text):
|
|
"""Show an information message box with given title and text."""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_error_message(self, title, text):
|
|
"""Show an error message box with given title and text."""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_start_info_collection_progress(self):
|
|
"""Open a indefinite progress bar for data collection.
|
|
|
|
This tells the user to wait while debug information is being
|
|
collected.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_pulse_info_collection_progress(self):
|
|
"""Advance the data collection progress bar.
|
|
|
|
This function is called every 100 ms.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_stop_info_collection_progress(self):
|
|
"""Close debug data collection progress window."""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_start_upload_progress(self):
|
|
"""Open progress bar for data upload.
|
|
|
|
This tells the user to wait while debug information is being uploaded.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_set_upload_progress(self, progress: float | None) -> None:
|
|
"""Update data upload progress bar.
|
|
|
|
Set the progress bar in the debug data upload progress window to the
|
|
given ratio (between 0 and 1, or None for indefinite progress).
|
|
|
|
This function is called every 100 ms.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_stop_upload_progress(self):
|
|
"""Close debug data upload progress window."""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_shutdown(self):
|
|
"""Called right before terminating the program.
|
|
|
|
This can be used for for cleaning up.
|
|
"""
|
|
|
|
def ui_has_terminal(self):
|
|
"""Check for a terminal window.
|
|
|
|
Check if a terminal application is available and can be launched.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_run_terminal(self, command):
|
|
"""Run command in a terminal window.
|
|
|
|
Run given command in a terminal window; raise an
|
|
exception if terminal cannot be opened.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
#
|
|
# Additional UI dialogs; these are not required by Apport itself, but can
|
|
# be used by interactive package hooks
|
|
#
|
|
|
|
def ui_question_yesno(self, text):
|
|
"""Show a yes/no question.
|
|
|
|
Return True if the user selected "Yes", False if selected "No" or
|
|
"None" on cancel/dialog closing.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_question_choice(self, text, options, multiple):
|
|
"""Show an question with predefined choices.
|
|
|
|
options is a list of strings to present. If multiple is True, they
|
|
should be check boxes, if multiple is False they should be radio
|
|
buttons.
|
|
|
|
Return list of selected option indexes, or None if the user cancelled.
|
|
If multiple == False, the list will always have one element.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
def ui_question_file(self, text):
|
|
"""Show a file selector dialog.
|
|
|
|
Return path if the user selected a file, or None if cancelled.
|
|
"""
|
|
raise NotImplementedError("this function must be overridden by subclasses")
|
|
|
|
|
|
class HookUI:
|
|
"""Interactive functions which can be used in package hooks.
|
|
|
|
This provides an interface for package hooks which need to ask interactive
|
|
questions. Directly passing the UserInterface instance to the hooks needs
|
|
to be avoided, since we need to call the UI methods in a different thread,
|
|
and also don't want hooks to be able to poke in the UI.
|
|
"""
|
|
|
|
def __init__(self, ui):
|
|
"""Create a HookUI object.
|
|
|
|
ui is the UserInterface instance to wrap.
|
|
"""
|
|
self.ui = ui
|
|
|
|
# variables for communicating with the UI thread
|
|
self._request_event = threading.Event()
|
|
self._response_event = threading.Event()
|
|
self._request_fn = None
|
|
self._request_args = None
|
|
self._response = None
|
|
|
|
#
|
|
# API for hooks
|
|
#
|
|
|
|
def information(self, text):
|
|
"""Show an information with OK/Cancel buttons.
|
|
|
|
This can be used for asking the user to perform a particular action,
|
|
such as plugging in a device which does not work.
|
|
"""
|
|
return self._trigger_ui_request("ui_info_message", "", text)
|
|
|
|
def yesno(self, text):
|
|
"""Show a yes/no question.
|
|
|
|
Return True if the user selected "Yes", False if selected "No" or
|
|
"None" on cancel/dialog closing.
|
|
"""
|
|
return self._trigger_ui_request("ui_question_yesno", text)
|
|
|
|
def choice(self, text, options, multiple=False):
|
|
"""Show an question with predefined choices.
|
|
|
|
options is a list of strings to present. If multiple is True, they
|
|
should be check boxes, if multiple is False they should be radio
|
|
buttons.
|
|
|
|
Return list of selected option indexes, or None if the user cancelled.
|
|
If multiple == False, the list will always have one element.
|
|
"""
|
|
return self._trigger_ui_request("ui_question_choice", text, options, multiple)
|
|
|
|
def file(self, text):
|
|
"""Show a file selector dialog.
|
|
|
|
Return path if the user selected a file, or None if cancelled.
|
|
"""
|
|
return self._trigger_ui_request("ui_question_file", text)
|
|
|
|
#
|
|
# internal API for inter-thread communication
|
|
#
|
|
|
|
def _trigger_ui_request(self, fn, *args):
|
|
"""Called by HookUi functions in info collection thread."""
|
|
# only one at a time
|
|
assert not self._request_event.is_set()
|
|
assert not self._response_event.is_set()
|
|
assert self._request_fn is None
|
|
|
|
self._response = None
|
|
self._request_fn = fn
|
|
self._request_args = args
|
|
self._request_event.set()
|
|
self._response_event.wait()
|
|
|
|
self._request_fn = None
|
|
self._response_event.clear()
|
|
|
|
return self._response
|
|
|
|
def process_event(self):
|
|
"""Called by GUI thread to check and process hook UI requests."""
|
|
# sleep for 0.1 seconds to wait for events
|
|
self._request_event.wait(0.1)
|
|
if not self._request_event.is_set():
|
|
return
|
|
|
|
assert not self._response_event.is_set()
|
|
self._request_event.clear()
|
|
self._response = getattr(self.ui, self._request_fn)(*self._request_args)
|
|
self._response_event.set()
|
|
|
|
|
|
class NoninteractiveHookUI(HookUI):
|
|
"""HookUI variant that does not ask the user any questions."""
|
|
|
|
def __init__(self):
|
|
super().__init__(None)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"{self.__class__.__name__}()"
|
|
|
|
def information(self, text):
|
|
return None
|
|
|
|
def yesno(self, text):
|
|
return None
|
|
|
|
def choice(self, text, options, multiple=False):
|
|
return None
|
|
|
|
def file(self, text):
|
|
return None
|
|
|
|
def process_event(self):
|
|
# Give other threads some chance to run
|
|
time.sleep(0.1)
|