Added dynamic X keyboard event monitoring to qvm_start_daemon.py
Update keyboard_layout property whenever guivm's layout changes, instead of only at the start. requires QubesOS/qubes-core-admin#350 references QubesOS/qubes-issues#1396 references QubesOS/qubes-issues#4294
This commit is contained in:
parent
ae39c75867
commit
1446a6d7ee
@ -35,6 +35,7 @@ import qubesadmin
|
|||||||
import qubesadmin.exc
|
import qubesadmin.exc
|
||||||
import qubesadmin.tools
|
import qubesadmin.tools
|
||||||
import qubesadmin.vm
|
import qubesadmin.vm
|
||||||
|
from . import xcffibhelpers
|
||||||
|
|
||||||
have_events = False
|
have_events = False
|
||||||
try:
|
try:
|
||||||
@ -73,6 +74,106 @@ REGEX_OUTPUT = re.compile(r"""
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardLayout:
|
||||||
|
"""Class to store and parse X Keyboard layout data"""
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
def __init__(self, binary_string):
|
||||||
|
split_string = binary_string.split(b'\0')
|
||||||
|
self.languages = split_string[2].decode().split(',')
|
||||||
|
self.variants = split_string[3].decode().split(',')
|
||||||
|
self.options = split_string[4].decode()
|
||||||
|
|
||||||
|
def get_property(self, layout_num):
|
||||||
|
"""Return the selected keyboard layout as formatted for keyboard_layout
|
||||||
|
property."""
|
||||||
|
return '+'.join([self.languages[layout_num],
|
||||||
|
self.variants[layout_num],
|
||||||
|
self.options])
|
||||||
|
|
||||||
|
|
||||||
|
class XWatcher:
|
||||||
|
"""Watch and react for X events related to the keyboard layout changes."""
|
||||||
|
def __init__(self, conn, app):
|
||||||
|
self.app = app
|
||||||
|
self.current_vm = self.app.domains[self.app.local_name]
|
||||||
|
|
||||||
|
self.conn = conn
|
||||||
|
self.ext = self.initialize_extension()
|
||||||
|
|
||||||
|
# get root window
|
||||||
|
self.setup = self.conn.get_setup()
|
||||||
|
self.root = self.setup.roots[0].root
|
||||||
|
|
||||||
|
# atoms (strings) of events we need to watch
|
||||||
|
# keyboard layout was switched
|
||||||
|
self.atom_xklavier = self.conn.core.InternAtom(
|
||||||
|
False, len("XKLAVIER_ALLOW_SECONDARY"),
|
||||||
|
"XKLAVIER_ALLOW_SECONDARY").reply().atom
|
||||||
|
# keyboard layout was changed
|
||||||
|
self.atom_xkb_rules = self.conn.core.InternAtom(
|
||||||
|
False, len("_XKB_RULES_NAMES"),
|
||||||
|
"_XKB_RULES_NAMES").reply().atom
|
||||||
|
|
||||||
|
self.conn.core.ChangeWindowAttributesChecked(
|
||||||
|
self.root, xcffib.xproto.CW.EventMask,
|
||||||
|
[xcffib.xproto.EventMask.PropertyChange])
|
||||||
|
self.conn.flush()
|
||||||
|
|
||||||
|
# initialize state
|
||||||
|
self.keyboard_layout = KeyboardLayout(self.get_keyboard_layout())
|
||||||
|
self.selected_layout = self.get_selected_layout()
|
||||||
|
|
||||||
|
def initialize_extension(self):
|
||||||
|
"""Initialize XKB extension (not supported by xcffib by default"""
|
||||||
|
ext = self.conn(xcffibhelpers.key)
|
||||||
|
ext.UseExtension()
|
||||||
|
return ext
|
||||||
|
|
||||||
|
def get_keyboard_layout(self):
|
||||||
|
"""Check what is current keyboard layout definition"""
|
||||||
|
property_cookie = self.conn.core.GetProperty(
|
||||||
|
False, # delete
|
||||||
|
self.root, # window
|
||||||
|
self.atom_xkb_rules,
|
||||||
|
xcffib.xproto.Atom.STRING,
|
||||||
|
0, 1000
|
||||||
|
)
|
||||||
|
prop_reply = property_cookie.reply()
|
||||||
|
return prop_reply.value.buf()
|
||||||
|
|
||||||
|
def get_selected_layout(self):
|
||||||
|
"""Check which keyboard layout is currently selected"""
|
||||||
|
state_reply = self.ext.GetState().reply()
|
||||||
|
return state_reply.lockedGroup[0]
|
||||||
|
|
||||||
|
def update_keyboard_layout(self):
|
||||||
|
"""Update current vm's keyboard_layout property"""
|
||||||
|
new_property = self.keyboard_layout.get_property(
|
||||||
|
self.selected_layout)
|
||||||
|
|
||||||
|
current_property = self.current_vm.keyboard_layout
|
||||||
|
|
||||||
|
if new_property != current_property:
|
||||||
|
self.current_vm.keyboard_layout = new_property
|
||||||
|
|
||||||
|
def event_reader(self, callback):
|
||||||
|
"""Poll for X events related to keyboard layout"""
|
||||||
|
try:
|
||||||
|
for event in iter(self.conn.poll_for_event, None):
|
||||||
|
if isinstance(event, xcffib.xproto.PropertyNotifyEvent):
|
||||||
|
if event.atom == self.atom_xklavier:
|
||||||
|
self.selected_layout = self.get_selected_layout()
|
||||||
|
elif event.atom == self.atom_xkb_rules:
|
||||||
|
self.keyboard_layout = KeyboardLayout(
|
||||||
|
self.get_keyboard_layout())
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.update_keyboard_layout()
|
||||||
|
except xcffib.ConnectionException:
|
||||||
|
callback()
|
||||||
|
|
||||||
|
|
||||||
def get_monitor_layout():
|
def get_monitor_layout():
|
||||||
"""Get list of monitors and their size/position"""
|
"""Get list of monitors and their size/position"""
|
||||||
outputs = []
|
outputs = []
|
||||||
@ -114,31 +215,6 @@ def get_monitor_layout():
|
|||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
def set_keyboard_layout(vm):
|
|
||||||
"""Set layout configuration into features for Gui admin extension"""
|
|
||||||
try:
|
|
||||||
# Examples of 'xprop -root _XKB_RULES_NAMES' output values:
|
|
||||||
# "evdev", "pc105", "fr", "oss", ""
|
|
||||||
# "evdev", "pc105", "pl,us", ",", "grp:win_switch,compose:caps"
|
|
||||||
|
|
||||||
# We use the first layout provided
|
|
||||||
xkb_re = r'_XKB_RULES_NAMES\(STRING\) = ' \
|
|
||||||
r'\"(.*)\", \"(.*)\", \"(.*)\", \"(.*)\", \"(.*)\"\n'
|
|
||||||
xkb_rules_names = subprocess.check_output(
|
|
||||||
['xprop', '-root', '_XKB_RULES_NAMES']).decode()
|
|
||||||
xkb_parsed = re.match(xkb_re, xkb_rules_names)
|
|
||||||
if xkb_parsed:
|
|
||||||
xkb_layout = [x.split(',')[0] for x in xkb_parsed.groups()[2:4]]
|
|
||||||
# We keep all options
|
|
||||||
xkb_layout.append(xkb_parsed.group(5))
|
|
||||||
keyboard_layout = '+'.join(xkb_layout)
|
|
||||||
vm.features['keyboard-layout'] = keyboard_layout
|
|
||||||
else:
|
|
||||||
vm.log.warning('Failed to parse layout for %s', vm)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
vm.log.warning('Failed to set layout for %s: %s', vm, str(e))
|
|
||||||
|
|
||||||
|
|
||||||
class DAEMONLauncher:
|
class DAEMONLauncher:
|
||||||
"""Launch GUI/AUDIO daemon for VMs"""
|
"""Launch GUI/AUDIO daemon for VMs"""
|
||||||
|
|
||||||
@ -461,17 +537,6 @@ class DAEMONLauncher:
|
|||||||
events.add_handler('connection-established',
|
events.add_handler('connection-established',
|
||||||
self.on_connection_established)
|
self.on_connection_established)
|
||||||
|
|
||||||
|
|
||||||
def x_reader(conn, callback):
|
|
||||||
"""Try reading something from X connection to check if it's still alive.
|
|
||||||
In case it isn't, call *callback*.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn.poll_for_event()
|
|
||||||
except xcffib.ConnectionException:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
|
|
||||||
if 'XDG_RUNTIME_DIR' in os.environ:
|
if 'XDG_RUNTIME_DIR' in os.environ:
|
||||||
pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
|
pidfile_path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
|
||||||
'qvm-start-daemon.pid')
|
'qvm-start-daemon.pid')
|
||||||
@ -492,9 +557,6 @@ parser.add_argument('--pidfile', action='store', default=pidfile_path,
|
|||||||
parser.add_argument('--notify-monitor-layout', action='store_true',
|
parser.add_argument('--notify-monitor-layout', action='store_true',
|
||||||
help='Notify running instance in --watch mode'
|
help='Notify running instance in --watch mode'
|
||||||
' about changed monitor layout')
|
' about changed monitor layout')
|
||||||
parser.add_argument('--set-keyboard-layout', action='store_true',
|
|
||||||
help='Set keyboard layout values into GuiVM features.'
|
|
||||||
'This option is implied by --watch')
|
|
||||||
# Add it for the help only
|
# Add it for the help only
|
||||||
parser.add_argument('--force', action='store_true', default=False,
|
parser.add_argument('--force', action='store_true', default=False,
|
||||||
help='Force running daemon without enabled services'
|
help='Force running daemon without enabled services'
|
||||||
@ -515,11 +577,6 @@ def main(args=None):
|
|||||||
parser.error('--watch option must be used with --all')
|
parser.error('--watch option must be used with --all')
|
||||||
if args.watch and args.notify_monitor_layout:
|
if args.watch and args.notify_monitor_layout:
|
||||||
parser.error('--watch cannot be used with --notify-monitor-layout')
|
parser.error('--watch cannot be used with --notify-monitor-layout')
|
||||||
if args.watch and 'guivm-gui-agent' in enabled_services:
|
|
||||||
args.set_keyboard_layout = True
|
|
||||||
if args.set_keyboard_layout or os.path.exists('/etc/qubes-release'):
|
|
||||||
guivm = args.app.domains.get_blind(args.app.local_name)
|
|
||||||
set_keyboard_layout(guivm)
|
|
||||||
launcher = DAEMONLauncher(args.app)
|
launcher = DAEMONLauncher(args.app)
|
||||||
if args.watch:
|
if args.watch:
|
||||||
if not have_events:
|
if not have_events:
|
||||||
@ -541,8 +598,10 @@ def main(args=None):
|
|||||||
launcher.send_monitor_layout_all)
|
launcher.send_monitor_layout_all)
|
||||||
|
|
||||||
conn = xcffib.connect()
|
conn = xcffib.connect()
|
||||||
|
x_watcher = XWatcher(conn, args.app)
|
||||||
x_fd = conn.get_file_descriptor()
|
x_fd = conn.get_file_descriptor()
|
||||||
loop.add_reader(x_fd, x_reader, conn, events_listener.cancel)
|
loop.add_reader(x_fd, x_watcher.event_reader,
|
||||||
|
events_listener.cancel)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(events_listener)
|
loop.run_until_complete(events_listener)
|
||||||
|
128
qubesadmin/tools/xcffibhelpers.py
Normal file
128
qubesadmin/tools/xcffibhelpers.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# -*- encoding: utf8 -*-
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, http://www.qubes-os.org
|
||||||
|
#
|
||||||
|
# Copyright (C) 2020 Marek Marczykowski-Górecki
|
||||||
|
# <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2.1 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License along
|
||||||
|
# with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
"""
|
||||||
|
This is a set of helper classes, designed to facilitate importing an X extension
|
||||||
|
that's not supported by default by xcffib.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
|
import xcffib
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class XkbUseExtensionReply(xcffib.Reply):
|
||||||
|
"""Helper class to parse XkbUseExtensionReply
|
||||||
|
Contains hardcoded values based on X11/XKBproto.h"""
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
def __init__(self, unpacker):
|
||||||
|
if isinstance(unpacker, xcffib.Protobj):
|
||||||
|
unpacker = xcffib.MemoryUnpacker(unpacker.pack())
|
||||||
|
xcffib.Reply.__init__(self, unpacker)
|
||||||
|
base = unpacker.offset
|
||||||
|
self.major_version, self.minor_version = unpacker.unpack(
|
||||||
|
"xx2x4xHH4x4x4x4x")
|
||||||
|
self.bufsize = unpacker.offset - base
|
||||||
|
|
||||||
|
|
||||||
|
class XkbUseExtensionCookie(xcffib.Cookie):
|
||||||
|
"""Helper class for use in loading Xkb extension"""
|
||||||
|
reply_type = XkbUseExtensionReply
|
||||||
|
|
||||||
|
|
||||||
|
class XkbGetStateReply(xcffib.Reply):
|
||||||
|
"""Helper class to parse XkbGetState; copy&paste from X11/XKBproto.h"""
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
_typedef = """
|
||||||
|
BYTE type;
|
||||||
|
BYTE deviceID;
|
||||||
|
CARD16 sequenceNumber B16;
|
||||||
|
CARD32 length B32;
|
||||||
|
CARD8 mods;
|
||||||
|
CARD8 baseMods;
|
||||||
|
CARD8 latchedMods;
|
||||||
|
CARD8 lockedMods;
|
||||||
|
CARD8 group;
|
||||||
|
CARD8 lockedGroup;
|
||||||
|
INT16 baseGroup B16;
|
||||||
|
INT16 latchedGroup B16;
|
||||||
|
CARD8 compatState;
|
||||||
|
CARD8 grabMods;
|
||||||
|
CARD8 compatGrabMods;
|
||||||
|
CARD8 lookupMods;
|
||||||
|
CARD8 compatLookupMods;
|
||||||
|
CARD8 pad1;
|
||||||
|
CARD16 ptrBtnState B16;
|
||||||
|
CARD16 pad2 B16;
|
||||||
|
CARD32 pad3 B32;"""
|
||||||
|
_type_mapping = {
|
||||||
|
"BYTE": "B",
|
||||||
|
"CARD16": "H",
|
||||||
|
"CARD8": "B",
|
||||||
|
"CARD32": "I",
|
||||||
|
"INT16": "h",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, unpacker):
|
||||||
|
if isinstance(unpacker, xcffib.Protobj):
|
||||||
|
unpacker = xcffib.MemoryUnpacker(unpacker.pack())
|
||||||
|
xcffib.Reply.__init__(self, unpacker)
|
||||||
|
base = unpacker.offset
|
||||||
|
|
||||||
|
# dynamic parse of copy&pasted struct content, for easy re-usability
|
||||||
|
for line in self._typedef.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
line = line.rstrip(';')
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
typename, name = line.split()[:2] # ignore optional third part
|
||||||
|
setattr(self, name, unpacker.unpack(self._type_mapping[typename]))
|
||||||
|
|
||||||
|
self.bufsize = unpacker.offset - base
|
||||||
|
|
||||||
|
|
||||||
|
class XkbGetStateCookie(xcffib.Cookie):
|
||||||
|
"""Helper class for use in parsing Xkb GetState"""
|
||||||
|
reply_type = XkbGetStateReply
|
||||||
|
|
||||||
|
|
||||||
|
class XkbExtension(xcffib.Extension):
|
||||||
|
"""Helper class to load and use Xkb xcffib extension; needed
|
||||||
|
because there is not XKB support in xcffib."""
|
||||||
|
# pylint: disable=invalid-name,missing-function-docstring
|
||||||
|
def UseExtension(self, is_checked=True):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
buf.write(struct.pack("=xx2xHH", 1, 0))
|
||||||
|
return self.send_request(0, buf, XkbGetStateCookie,
|
||||||
|
is_checked=is_checked)
|
||||||
|
|
||||||
|
def GetState(self, deviceSpec=0x100, is_checked=True):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
buf.write(struct.pack("=xx2xHxx", deviceSpec))
|
||||||
|
return self.send_request(4, buf, XkbGetStateCookie,
|
||||||
|
is_checked=is_checked)
|
||||||
|
|
||||||
|
|
||||||
|
key = xcffib.ExtensionKey("XKEYBOARD")
|
||||||
|
# this is a lie: there are events and errors types
|
||||||
|
_events = {}
|
||||||
|
_errors = {}
|
||||||
|
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
xcffib._add_ext(key, XkbExtension, _events, _errors)
|
6
setup.py
6
setup.py
@ -17,9 +17,11 @@ def get_console_scripts():
|
|||||||
if sys.version_info[0:2] >= (3, 4):
|
if sys.version_info[0:2] >= (3, 4):
|
||||||
for filename in os.listdir('./qubesadmin/tools'):
|
for filename in os.listdir('./qubesadmin/tools'):
|
||||||
basename, ext = os.path.splitext(os.path.basename(filename))
|
basename, ext = os.path.splitext(os.path.basename(filename))
|
||||||
if basename in ['__init__', 'dochelpers'] or ext != '.py':
|
if basename in ['__init__', 'dochelpers', 'xcffibhelpers']\
|
||||||
|
or ext != '.py':
|
||||||
continue
|
continue
|
||||||
yield basename.replace('_', '-'), 'qubesadmin.tools.{}'.format(basename)
|
yield basename.replace('_', '-'), 'qubesadmin.tools.{}'.format(
|
||||||
|
basename)
|
||||||
|
|
||||||
# create simple scripts that run much faster than "console entry points"
|
# create simple scripts that run much faster than "console entry points"
|
||||||
class CustomInstall(setuptools.command.install.install):
|
class CustomInstall(setuptools.command.install.install):
|
||||||
|
Loading…
Reference in New Issue
Block a user