Merge remote-tracking branch 'origin/pr/144'
* origin/pr/144: Clean up the guid-conf file on domain stop Generate qubes-guid options based on features
This commit is contained in:
commit
470514d0dc
@ -82,6 +82,19 @@ See also `gui` feature.
|
|||||||
If neither `gui` nor `gui-emulated` is set, emulated VGA is used (if
|
If neither `gui` nor `gui-emulated` is set, emulated VGA is used (if
|
||||||
applicable for given VM virtualization mode).
|
applicable for given VM virtualization mode).
|
||||||
|
|
||||||
|
gui-\*, gui-default-\*
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
GUI daemon configuration. See `/etc/qubes/guid.conf` for a list of supported
|
||||||
|
options.
|
||||||
|
|
||||||
|
To change a given GUI option for a specific qube, set the `gui-{option}`
|
||||||
|
feature (with underscores replaced with dashes). For example, to enable
|
||||||
|
`allow_utf8_titles` for a qube, set `gui-allow-utf8-titles` to `True`.
|
||||||
|
|
||||||
|
To change a given GUI option globally, set the `gui-default-{option}` feature
|
||||||
|
on the GuiVM for that qube.
|
||||||
|
|
||||||
qrexec
|
qrexec
|
||||||
^^^^^^
|
^^^^^^
|
||||||
|
|
||||||
|
@ -22,11 +22,13 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
import re
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import qubesadmin.tests
|
import qubesadmin.tests
|
||||||
import qubesadmin.tools.qvm_start_daemon
|
import qubesadmin.tools.qvm_start_daemon
|
||||||
|
from qubesadmin.tools.qvm_start_daemon import GUI_DAEMON_OPTIONS
|
||||||
import qubesadmin.vm
|
import qubesadmin.vm
|
||||||
|
|
||||||
|
|
||||||
@ -61,16 +63,20 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase):
|
|||||||
|
|
||||||
self.assertAllCalled()
|
self.assertAllCalled()
|
||||||
|
|
||||||
def test_010_common_args(self):
|
def setup_common_args(self):
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('dom0', 'admin.vm.List', None, None)] = \
|
('dom0', 'admin.vm.List', None, None)] = \
|
||||||
b'0\x00test-vm class=AppVM state=Running\n'
|
b'0\x00test-vm class=AppVM state=Running\n' \
|
||||||
|
b'gui-vm class=AppVM state=Running'
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('test-vm', 'admin.vm.property.Get', 'label', None)] = \
|
('test-vm', 'admin.vm.property.Get', 'label', None)] = \
|
||||||
b'0\x00default=False type=label red'
|
b'0\x00default=False type=label red'
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
|
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
|
||||||
b'0\x00default=False type=bool False'
|
b'0\x00default=False type=bool False'
|
||||||
|
self.app.expected_calls[
|
||||||
|
('test-vm', 'admin.vm.property.Get', 'guivm', None)] = \
|
||||||
|
b'0\x00default=False type=vm gui-vm'
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('dom0', 'admin.label.Get', 'red', None)] = \
|
('dom0', 'admin.label.Get', 'red', None)] = \
|
||||||
b'0\x000xff0000'
|
b'0\x000xff0000'
|
||||||
@ -82,86 +88,124 @@ class TC_00_qvm_start_gui(qubesadmin.tests.QubesTestCase):
|
|||||||
'rpc-clipboard', None)] = \
|
'rpc-clipboard', None)] = \
|
||||||
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
|
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \
|
self.app.expected_calls[
|
||||||
kde_mock:
|
('test-vm', 'admin.vm.property.Get', 'xid', None)] = \
|
||||||
|
b'0\x00default=99 type=int 99'
|
||||||
|
|
||||||
|
for name, _kind in GUI_DAEMON_OPTIONS:
|
||||||
|
self.app.expected_calls[
|
||||||
|
('test-vm', 'admin.vm.feature.Get',
|
||||||
|
'gui-' + name.replace('_', '-'), None)] = \
|
||||||
|
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
|
||||||
|
|
||||||
|
self.app.expected_calls[
|
||||||
|
('gui-vm', 'admin.vm.feature.Get',
|
||||||
|
'gui-default-' + name.replace('_', '-'), None)] = \
|
||||||
|
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
|
||||||
|
|
||||||
|
def run_common_args(self):
|
||||||
|
with unittest.mock.patch.object(
|
||||||
|
self.launcher, 'kde_guid_args') as kde_mock, \
|
||||||
|
unittest.mock.patch.object(
|
||||||
|
self.launcher, 'write_guid_config') as write_config_mock:
|
||||||
kde_mock.return_value = []
|
kde_mock.return_value = []
|
||||||
|
|
||||||
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
|
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
|
||||||
self.assertEqual(args, [
|
|
||||||
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
self.assertEqual(len(write_config_mock.mock_calls), 1)
|
||||||
'-c', '0xff0000',
|
|
||||||
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
config_args = write_config_mock.mock_calls[0][1]
|
||||||
'-l', '1', '-q'])
|
self.assertEqual(config_args[0], '/var/run/qubes/guid-conf.99')
|
||||||
|
config = config_args[1]
|
||||||
|
|
||||||
|
# Strip comments and empty lines
|
||||||
|
config = re.sub(r'^#.*\n', '', config)
|
||||||
|
config = re.sub(r'^\n', '', config)
|
||||||
|
|
||||||
self.assertAllCalled()
|
self.assertAllCalled()
|
||||||
|
return args, config
|
||||||
|
|
||||||
|
def test_010_common_args(self):
|
||||||
|
self.setup_common_args()
|
||||||
|
|
||||||
|
args, config = self.run_common_args()
|
||||||
|
self.assertEqual(args, [
|
||||||
|
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
||||||
|
'-c', '0xff0000',
|
||||||
|
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
||||||
|
'-l', '1', '-q',
|
||||||
|
'-C', '/var/run/qubes/guid-conf.99',
|
||||||
|
])
|
||||||
|
|
||||||
|
self.assertEqual(config, '''\
|
||||||
|
global: {
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
def test_011_common_args_debug(self):
|
def test_011_common_args_debug(self):
|
||||||
self.app.expected_calls[
|
self.setup_common_args()
|
||||||
('dom0', 'admin.vm.List', None, None)] = \
|
|
||||||
b'0\x00test-vm class=AppVM state=Running\n'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('test-vm', 'admin.vm.property.Get', 'label', None)] = \
|
|
||||||
b'0\x00default=False type=label red'
|
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
|
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
|
||||||
b'0\x00default=False type=bool True'
|
b'0\x00default=False type=bool True'
|
||||||
self.app.expected_calls[
|
|
||||||
('dom0', 'admin.label.Get', 'red', None)] = \
|
|
||||||
b'0\x000xff0000'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('dom0', 'admin.label.Index', 'red', None)] = \
|
|
||||||
b'0\x001'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('test-vm', 'admin.vm.feature.CheckWithTemplate',
|
|
||||||
'rpc-clipboard', None)] = \
|
|
||||||
b'2\x00QubesFeatureNotFoundError\x00\x00Feature not set\x00'
|
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \
|
args, config = self.run_common_args()
|
||||||
kde_mock:
|
self.assertEqual(args, [
|
||||||
kde_mock.return_value = []
|
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
||||||
|
'-c', '0xff0000',
|
||||||
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
|
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
||||||
self.assertEqual(args, [
|
'-l', '1', '-v', '-v',
|
||||||
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
'-C', '/var/run/qubes/guid-conf.99',
|
||||||
'-c', '0xff0000',
|
])
|
||||||
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
self.assertEqual(config, '''\
|
||||||
'-l', '1', '-v', '-v'])
|
global: {
|
||||||
|
}
|
||||||
self.assertAllCalled()
|
''')
|
||||||
|
|
||||||
def test_012_common_args_rpc_clipboard(self):
|
def test_012_common_args_rpc_clipboard(self):
|
||||||
self.app.expected_calls[
|
self.setup_common_args()
|
||||||
('dom0', 'admin.vm.List', None, None)] = \
|
|
||||||
b'0\x00test-vm class=AppVM state=Running\n'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('test-vm', 'admin.vm.property.Get', 'label', None)] = \
|
|
||||||
b'0\x00default=False type=label red'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('test-vm', 'admin.vm.property.Get', 'debug', None)] = \
|
|
||||||
b'0\x00default=False type=bool False'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('dom0', 'admin.label.Get', 'red', None)] = \
|
|
||||||
b'0\x000xff0000'
|
|
||||||
self.app.expected_calls[
|
|
||||||
('dom0', 'admin.label.Index', 'red', None)] = \
|
|
||||||
b'0\x001'
|
|
||||||
self.app.expected_calls[
|
self.app.expected_calls[
|
||||||
('test-vm', 'admin.vm.feature.CheckWithTemplate',
|
('test-vm', 'admin.vm.feature.CheckWithTemplate',
|
||||||
'rpc-clipboard', None)] = \
|
'rpc-clipboard', None)] = \
|
||||||
b'0\x001'
|
b'0\x001'
|
||||||
|
|
||||||
with unittest.mock.patch.object(self.launcher, 'kde_guid_args') as \
|
args, config = self.run_common_args()
|
||||||
kde_mock:
|
|
||||||
kde_mock.return_value = []
|
|
||||||
|
|
||||||
args = self.launcher.common_guid_args(self.app.domains['test-vm'])
|
self.assertEqual(args, [
|
||||||
self.assertEqual(args, [
|
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
||||||
'/usr/bin/qubes-guid', '-N', 'test-vm',
|
'-c', '0xff0000',
|
||||||
'-c', '0xff0000',
|
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
||||||
'-i', '/usr/share/icons/hicolor/128x128/devices/appvm-red.png',
|
'-l', '1', '-q', '-Q',
|
||||||
'-l', '1', '-q', '-Q'])
|
'-C', '/var/run/qubes/guid-conf.99',
|
||||||
|
])
|
||||||
|
self.assertEqual(config, '''\
|
||||||
|
global: {
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
self.assertAllCalled()
|
def test_013_common_args_guid_config(self):
|
||||||
|
self.setup_common_args()
|
||||||
|
|
||||||
|
self.app.expected_calls[
|
||||||
|
('test-vm', 'admin.vm.feature.Get',
|
||||||
|
'gui-allow-fullscreen', None)] = \
|
||||||
|
b'0\x001'
|
||||||
|
# The template will not be asked for this feature
|
||||||
|
del self.app.expected_calls[
|
||||||
|
('gui-vm', 'admin.vm.feature.Get',
|
||||||
|
'gui-default-allow-fullscreen', None)]
|
||||||
|
|
||||||
|
self.app.expected_calls[
|
||||||
|
('gui-vm', 'admin.vm.feature.Get',
|
||||||
|
'gui-default-secure-copy-sequence', None)] = \
|
||||||
|
b'0\x00Ctrl-Alt-Shift-c'
|
||||||
|
|
||||||
|
_args, config = self.run_common_args()
|
||||||
|
self.assertEqual(config, '''\
|
||||||
|
global: {
|
||||||
|
allow_fullscreen = true;
|
||||||
|
secure_copy_sequence = "Ctrl-Alt-Shift-c";
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
@unittest.mock.patch('asyncio.create_subprocess_exec')
|
@unittest.mock.patch('asyncio.create_subprocess_exec')
|
||||||
def test_020_start_gui_for_vm(self, proc_mock):
|
def test_020_start_gui_for_vm(self, proc_mock):
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# -*- encoding: utf8 -*-
|
# -*- encoding: utf-8 -*-
|
||||||
#
|
#
|
||||||
# The Qubes OS Project, http://www.qubes-os.org
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
#
|
#
|
||||||
@ -49,6 +49,109 @@ GUI_DAEMON_PATH = '/usr/bin/qubes-guid'
|
|||||||
PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan'
|
PACAT_DAEMON_PATH = '/usr/bin/pacat-simple-vchan'
|
||||||
QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices'
|
QUBES_ICON_DIR = '/usr/share/icons/hicolor/128x128/devices'
|
||||||
|
|
||||||
|
GUI_DAEMON_OPTIONS = [
|
||||||
|
('allow_fullscreen', 'bool'),
|
||||||
|
('override_redirect_protection', 'bool'),
|
||||||
|
('allow_utf8_titles', 'bool'),
|
||||||
|
('secure_copy_sequence', 'str'),
|
||||||
|
('secure_paste_sequence', 'str'),
|
||||||
|
('windows_count_limit', 'int'),
|
||||||
|
('trayicon_mode', 'str'),
|
||||||
|
('startup_timeout', 'int'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_gui_daemon_options(vm, guivm):
|
||||||
|
'''
|
||||||
|
Construct a list of GUI daemon options based on VM features.
|
||||||
|
|
||||||
|
This checks 'gui-*' features on the VM, and if they're absent,
|
||||||
|
'gui-default-*' features on the GuiVM.
|
||||||
|
'''
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
for name, kind in GUI_DAEMON_OPTIONS:
|
||||||
|
feature_value = vm.features.get(
|
||||||
|
'gui-' + name.replace('_', '-'), None)
|
||||||
|
if feature_value is None:
|
||||||
|
feature_value = guivm.features.get(
|
||||||
|
'gui-default-' + name.replace('_', '-'), None)
|
||||||
|
if feature_value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if kind == 'bool':
|
||||||
|
value = bool(feature_value)
|
||||||
|
elif kind == 'int':
|
||||||
|
value = int(feature_value)
|
||||||
|
elif kind == 'str':
|
||||||
|
value = feature_value
|
||||||
|
else:
|
||||||
|
assert False, kind
|
||||||
|
|
||||||
|
options[name] = value
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_gui_daemon_options(options):
|
||||||
|
'''
|
||||||
|
Prepare configuration file content for GUI daemon. Currently uses libconfig
|
||||||
|
format.
|
||||||
|
'''
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
'# Auto-generated file, do not edit!',
|
||||||
|
'',
|
||||||
|
'global: {',
|
||||||
|
]
|
||||||
|
for name, kind in GUI_DAEMON_OPTIONS:
|
||||||
|
if name in options:
|
||||||
|
value = options[name]
|
||||||
|
if kind == 'bool':
|
||||||
|
serialized = 'true' if value else 'false'
|
||||||
|
elif kind == 'int':
|
||||||
|
serialized = str(value)
|
||||||
|
elif kind == 'str':
|
||||||
|
serialized = escape_config_string(value)
|
||||||
|
else:
|
||||||
|
assert False, kind
|
||||||
|
|
||||||
|
lines.append(' {} = {};'.format(name, serialized))
|
||||||
|
lines.append('}')
|
||||||
|
lines.append('')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
NON_ASCII_RE = re.compile(r'[^\x00-\x7F]')
|
||||||
|
UNPRINTABLE_CHARACTER_RE = re.compile(r'[\x00-\x1F\x7F]')
|
||||||
|
|
||||||
|
def escape_config_string(value):
|
||||||
|
'''
|
||||||
|
Convert a string to libconfig format.
|
||||||
|
|
||||||
|
Format specification:
|
||||||
|
http://www.hyperrealm.com/libconfig/libconfig_manual.html#String-Values
|
||||||
|
|
||||||
|
See dump_string() for python-libconf:
|
||||||
|
https://github.com/Grk0/python-libconf/blob/master/libconf.py
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert not NON_ASCII_RE.match(value), 'expected an ASCII string: {!r}'.format(value)
|
||||||
|
|
||||||
|
value = (
|
||||||
|
value.replace('\\', '\\\\')
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace('\f', r'\f')
|
||||||
|
.replace('\n', r'\n')
|
||||||
|
.replace('\r', r'\r')
|
||||||
|
.replace('\t', r'\t')
|
||||||
|
)
|
||||||
|
value = UNPRINTABLE_CHARACTER_RE.sub(
|
||||||
|
lambda m: r'\x{:02x}'.format(ord(m.group(0))),
|
||||||
|
value)
|
||||||
|
return '"' + value + '"'
|
||||||
|
|
||||||
|
|
||||||
# "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
|
# "LVDS connected 1024x768+0+0 (normal left inverted right) 304mm x 228mm"
|
||||||
REGEX_OUTPUT = re.compile(r"""
|
REGEX_OUTPUT = re.compile(r"""
|
||||||
(?x) # ignore whitespace
|
(?x) # ignore whitespace
|
||||||
@ -240,8 +343,7 @@ class DAEMONLauncher:
|
|||||||
vm.label.name + '.colors'))]
|
vm.label.name + '.colors'))]
|
||||||
return guid_cmd
|
return guid_cmd
|
||||||
|
|
||||||
@staticmethod
|
def common_guid_args(self, vm):
|
||||||
def common_guid_args(vm):
|
|
||||||
"""Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
|
"""Common qubes-guid arguments for PV(H), HVM and Stubdomain"""
|
||||||
|
|
||||||
guid_cmd = [GUI_DAEMON_PATH,
|
guid_cmd = [GUI_DAEMON_PATH,
|
||||||
@ -259,13 +361,30 @@ class DAEMONLauncher:
|
|||||||
if vm.features.check_with_template('rpc-clipboard', False):
|
if vm.features.check_with_template('rpc-clipboard', False):
|
||||||
guid_cmd.extend(['-Q'])
|
guid_cmd.extend(['-Q'])
|
||||||
|
|
||||||
|
guivm = self.app.domains[vm.guivm]
|
||||||
|
options = retrieve_gui_daemon_options(vm, guivm)
|
||||||
|
config = serialize_gui_daemon_options(options)
|
||||||
|
config_path = self.guid_config_file(vm.xid)
|
||||||
|
self.write_guid_config(config_path, config)
|
||||||
|
guid_cmd.extend(['-C', config_path])
|
||||||
return guid_cmd
|
return guid_cmd
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_guid_config(config_path, config):
|
||||||
|
"""Write guid configuration to a file"""
|
||||||
|
with open(config_path, 'w') as config_file:
|
||||||
|
config_file.write(config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def guid_pidfile(xid):
|
def guid_pidfile(xid):
|
||||||
"""Helper function to construct a GUI pidfile path"""
|
"""Helper function to construct a GUI pidfile path"""
|
||||||
return '/var/run/qubes/guid-running.{}'.format(xid)
|
return '/var/run/qubes/guid-running.{}'.format(xid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def guid_config_file(xid):
|
||||||
|
"""Helper function to construct a GUI configuration file path"""
|
||||||
|
return '/var/run/qubes/guid-conf.{}'.format(xid)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def pacat_pidfile(xid):
|
def pacat_pidfile(xid):
|
||||||
"""Helper function to construct an AUDIO pidfile path"""
|
"""Helper function to construct an AUDIO pidfile path"""
|
||||||
@ -452,12 +571,29 @@ class DAEMONLauncher:
|
|||||||
asyncio.ensure_future(
|
asyncio.ensure_future(
|
||||||
self.start_gui_for_stubdomain(vm))
|
self.start_gui_for_stubdomain(vm))
|
||||||
|
|
||||||
|
def on_domain_stopped(self, vm, _event, **_kwargs):
|
||||||
|
"""Handler of 'domain-stopped' event, cleans up"""
|
||||||
|
self.cleanup_guid(vm.xid)
|
||||||
|
if vm.virt_mode == 'hvm':
|
||||||
|
self.cleanup_guid(vm.stubdom_xid)
|
||||||
|
|
||||||
|
def cleanup_guid(self, xid):
|
||||||
|
"""
|
||||||
|
Clean up after qubes-guid. Removes the auto-generated configuration
|
||||||
|
file, if any.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config_path = self.guid_config_file(xid)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
os.unlink(config_path)
|
||||||
|
|
||||||
def register_events(self, events):
|
def register_events(self, events):
|
||||||
"""Register domain startup events in app.events dispatcher"""
|
"""Register domain startup events in app.events dispatcher"""
|
||||||
events.add_handler('domain-spawn', self.on_domain_spawn)
|
events.add_handler('domain-spawn', self.on_domain_spawn)
|
||||||
events.add_handler('domain-start', self.on_domain_start)
|
events.add_handler('domain-start', self.on_domain_start)
|
||||||
events.add_handler('connection-established',
|
events.add_handler('connection-established',
|
||||||
self.on_connection_established)
|
self.on_connection_established)
|
||||||
|
events.add_handler('domain-stopped', self.on_domain_stopped)
|
||||||
|
|
||||||
|
|
||||||
def x_reader(conn, callback):
|
def x_reader(conn, callback):
|
||||||
|
Loading…
Reference in New Issue
Block a user