dbus: Add internal API suitable for GlobalShortcuts

https://gitlab.gnome.org/GNOME/xdg-desktop-portal-gnome/-/issues/47#note_2089569

This adds an interface in org.gnome.Shell that provides additional functionality
for managing key grabs, suitable for the GlobalShortcuts portal.
This commit is contained in:
dcz 2024-03-20 08:52:39 +01:00 committed by dcz
parent dc6f7a1289
commit 681b846fb2
2 changed files with 355 additions and 14 deletions

View file

@ -17,9 +17,82 @@
<arg type="s" direction="in" name="id"/>
</method>
<method name="ShowApplications"/>
<!-- START SHORTCUTS
Designed to handle shortcuts for the globalshortcuts portal.
`group_id` identifies a group of shortcuts meant to be modified together.
-->
<!-- (Re)binds shortcuts according to spec.
`shortcuts` is made of pairs:
(id: s, {'shortcuts'=Variant(as)})
for example:
('sstop', {'description': 'Stop the show', 'shortcuts': <['<alt>C', '<ctrl>Q']>})
`results['shortcuts']` is the list of shortcuts now in effect:
(id: s, {'trigger_description'=Variant(s)})
for example:
('sstop', {'description': 'Stop the show', 'trigger_description': <['hold alt and then C', 'the Q key while holding Control']>})
'trigger_description's are free-form, human-readable text.
`changed` is nonzero if registered shortcuts changed because of this call, zero otherwise.
The 'description' key is optional.
FIXME: trigger release events when a combo is being pressed while unbinding?
-->
<method name="BindShortcuts">
<arg type="s" name="group_id" direction="in"/>
<arg type="a(sa{sv})" name="shortcuts" direction="in"/>
<arg type="a{sv}" name="results" direction="out"/>
<arg type="u" name="changed" direction="out"/>
</method>
<!-- Unbinds shortcuts in the selected group.
Equivalent to calling BindShortcuts with an empty list.
-->
<method name="UnbindShortcuts">
<arg type="s" name="group_id" direction="in"/>
</method>
<!-- Lists all shortcuts of the group, together with their triggers.
If the group never bound shortcuts, this returns an error.
`results['shortcuts']` is a list of pairs:
(id: s, {'description'=Variant(s), 'trigger_description'=Variant(as))})
for example:
('sstop', {'description': 'Stop the show', 'trigger_description': <['hold alt and then C', 'the Q key while holding Control']>})
The 'description' key is optional.
-->
<method name="ListShortcuts">
<arg type="s" name="group_id" direction="in"/>
<arg type="a{sv}" name="results" direction="out"/>
</method>
<!-- Sent whenever a registered combo becomes pressed.
Sent back to the client that registered it. -->
<signal name="Activated">
<arg type="s" name="group_id" direction="out"/>
<arg type="s" name="shortcut_id" direction="out"/>
<arg type="t" name="timestamp" direction="out"/>
</signal>
<!-- Sent whenever a registered combo stops being pressed.
Sent back to the client that registered it. -->
<signal name="Deactivated">
<arg type="s" name="group_id" direction="out"/>
<arg type="s" name="shortcut_id" direction="out"/>
<arg type="t" name="timestamp" direction="out"/>
</signal>
<!-- END SHORTCUTS -->
<method name="GrabAccelerator">
<arg type="s" direction="in" name="accelerator"/>
<!-- Shell.ActionMode -->
<arg type="u" direction="in" name="modeFlags"/>
<!-- Meta.KeyBindingFlags -->
<arg type="u" direction="in" name="grabFlags"/>
<arg type="u" direction="out" name="action"/>
</method>

View file

@ -18,6 +18,18 @@ import {ControlsState} from './overviewControls.js';
const GnomeShellIface = loadInterfaceXML('org.gnome.Shell');
const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
function dbg_(v) {
console.error(v);
return v;
}
const eqSet = (xs, ys) =>
xs.size === ys.size &&
[...xs].every(x => ys.has(x));
export class GnomeShell {
constructor() {
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellIface, this);
@ -34,10 +46,24 @@ export class GnomeShell {
this._grabbedAccelerators = new Map();
this._grabbers = new Map();
/* session: string [
* sender: string,
* [(
* name: string,
* shortcuts: [trigger: string, action: int],
* )]
* ]
*/
this._accelerators = new Map();
global.display.connect('accelerator-activated',
(display, action, device, timestamp) => {
this._emitAcceleratorActivated(action, device, timestamp);
});
global.display.connect('accelerator-deactivated',
(display, action, device, timestamp) => {
this._emitAcceleratorDeactivated(action, device, timestamp);
});
this._cachedOverviewVisible = false;
Main.overview.connect('showing',
@ -194,6 +220,66 @@ export class GnomeShell {
invocation.return_value(null);
}
async BindShortcutsAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
let [groupId, shortcuts] = params;
let [remainingShortcuts, changed] = this._bindShortcuts(groupId, shortcuts, invocation.get_sender());
invocation.return_value(GLib.Variant.new(
'(a{sv}u)',
[
{'shortcuts': GLib.Variant.new('a(sa{sv})', remainingShortcuts)},
changed,
]
));
}
async UnbindShortcutsAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
let [groupId] = params;
this._bindShortcuts(groupId, [], invocation.get_sender());
invocation.return_value(null);
}
async ListShortcutsAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
let [groupId] = params;
if (this._accelerators.has(groupId)) {
invocation.return_value(GLib.Variant.new(
'(a{sv})',
[{'shortcuts': GLib.Variant.new('a(sa{sv})', this._listShortcuts(groupId))}]
));
} else {
invocation.return_error_literal(
Gio.DBusError,
Gio.DBusError.INVALID_ARGS,
'No such group'
);
}
}
async GrabAcceleratorAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
@ -272,28 +358,206 @@ export class GnomeShell {
invocation.return_value(null);
}
_emitAcceleratorActivated(action, device, timestamp) {
_bindShortcuts(groupId, shortcuts, sender) {
let cleanedShortcuts = shortcuts.map(([name, info]) => [
name,
info['shortcuts'] ? info['shortcuts'].unpack() : [],
info['description'] ? info['description'].unpack() : undefined,
]);
let existing = [];
if (this._accelerators.has(groupId)) {
let [sender_, ex] = this._accelerators.get(groupId);
existing = ex;
}
// MDN says the last key is the one preserved in the final Map.
let descriptions = new Map([
...existing.map(([name, _, desc]) => [name, desc]),
...cleanedShortcuts.map(([name, _, desc]) => [name, desc]),
]);
let requested = new Map(cleanedShortcuts);
// Shortcuts to (re)bind.
let changed = [];
// The following 2 mark things to unbind.
// Names may get immediately be bound again.
let removedNames = [];
for (let [name, actions, desc_] of existing) {
if (requested.has(name)) {
let reqTriggers = requested.get(name);
let triggers = actions.map(([trigger, _]) => trigger);
const unpack = arr => arr.map(t => t.unpack());
if (!eqSet(new Set(unpack(reqTriggers)), new Set(unpack(triggers)))) {
changed.push([name, reqTriggers]);
removedNames.push(name);
}
} else {
removedNames.push(name);
}
}
let present = new Map(existing);
for (let [name, triggers, desc_] of cleanedShortcuts) {
if (!present.has(name))
changed.push([name, triggers]);
}
let addedShortcuts = changed.map(([name, triggers]) => [name, triggers, descriptions.get(name)]);
// FIXME: is this a race condition? Can input events be processed between these calls?
if (removedNames.length > 0)
this._unbindShortcuts(groupId, removedNames);
this._bindShortcutsForSender(groupId, addedShortcuts, sender);
let kept = new Map(changed);
let removedIds = removedNames.filter(name => !kept.has(name));
let remainingShortcuts = this._listShortcuts(groupId);
if (remainingShortcuts.length === 0)
this._accelerators.delete(groupId);
return [remainingShortcuts, 0 + (changed.length + removedIds.length > 0)];
}
// Each shortcut is being bound separately and as a whole.
// If there's an existing bound shortcut with the same name,
// the new one will not take effect.
_bindShortcutsForSender(groupId, shortcuts, sender) {
let [sender_, existing] = this._accelerators.get(groupId) || [null, []];
let existingNames = new Set(existing.map(([name, _actions, _desc]) => name));
let addedShortcuts = [];
for (let [name, triggers, description] of shortcuts) {
if (existingNames.has(name)) {
console.error('Tried to bind over an existing shortcut', name, 'in group', groupId);
continue;
}
let actions = [];
for (let trigger of triggers) {
let bindingAction = this._grabAcceleratorForSender(
trigger.unpack(),
Shell.ActionMode.ALL,
Meta.KeyBindingFlags.TRIGGER_RELEASE,
sender
);
actions.push([trigger, bindingAction]);
}
addedShortcuts.push([name, actions, description]);
}
this._accelerators.set(groupId, [sender, existing.concat(addedShortcuts)]);
}
_listShortcuts(groupId) {
let [sender_, rawShortcuts] = this._accelerators.get(groupId);
function shortcutToDbus(name, triggers, description) {
let info = {'trigger_description': GLib.Variant.new('as', triggers.map(t => t.unpack()))};
if (description !== undefined)
info.description = GLib.Variant.new('s', description);
return [name, info];
}
return rawShortcuts.map(
([name, shortcuts, description]) => shortcutToDbus(
name,
shortcuts.map(([trigger, _]) => trigger),
description
)
);
}
_unbindShortcuts(groupId, names) {
let namesSet = new Set(names);
let [sender, rawShortcuts] = this._accelerators.get(groupId);
let ungrabFailed = 0;
let newRaw = [];
for (let [name, shortcuts, desc] of rawShortcuts) {
if (namesSet.has(name)) {
for (let [trigger_, action] of shortcuts)
ungrabFailed += this._ungrabAcceleratorForSender(action, sender) ? 0 : 1;
} else {
newRaw.push([name, shortcuts, desc]);
}
}
if (ungrabFailed)
console.error(`Failed to remove ${ungrabFailed} shortcuts for ${groupId}`);
if (newRaw.length > 0)
this._accelerators.set(groupId, [sender, newRaw]);
else
this._accelerators.delete(groupId);
console.warn(`Some shortcuts were unbound in group ${groupId}`);
}
_emitAcceleratorTriggered(action, device, timestamp, activated) {
let destination = this._grabbedAccelerators.get(action);
if (!destination)
return;
let connection = this._dbusImpl.get_connection();
let info = this._dbusImpl.get_info();
let params = {
'timestamp': GLib.Variant.new('u', timestamp),
'action-mode': GLib.Variant.new('u', Main.actionMode),
};
let shortcutData;
for (let [group, data] of this._accelerators) {
let [sender, rawShortcuts] = data;
if (sender === destination) {
for (let [name, shortcuts] of rawShortcuts) {
for (let [_, savedAction] of shortcuts) {
if (action === savedAction) {
shortcutData = [group, name];
break;
}
}
}
}
}
let deviceNode = device.get_device_node();
if (deviceNode)
params['device-node'] = GLib.Variant.new('s', deviceNode);
if (shortcutData === undefined) {
if (!activated)
return;
connection.emit_signal(
destination,
this._dbusImpl.get_object_path(),
info?.name ?? null,
'AcceleratorActivated',
GLib.Variant.new('(ua{sv})', [action, params]));
let params = {
'timestamp': GLib.Variant.new('u', timestamp),
'action-mode': GLib.Variant.new('u', Main.actionMode),
};
let deviceNode = device.get_device_node();
if (deviceNode)
params['device-node'] = GLib.Variant.new('s', deviceNode);
connection.emit_signal(
destination,
this._dbusImpl.get_object_path(),
info?.name ?? null,
'AcceleratorActivated',
GLib.Variant.new('(ua{sv})', [action, params]));
} else {
let [group, name] = shortcutData;
connection.emit_signal(
destination,
this._dbusImpl.get_object_path(),
info?.name ?? null,
activated ? 'Activated' : 'Deactivated',
GLib.Variant.new('(sst)', [group, name, timestamp]));
}
}
_emitAcceleratorActivated(action, device, timestamp) {
return this._emitAcceleratorTriggered(action, device, timestamp, true);
}
_emitAcceleratorDeactivated(action, device, timestamp) {
return this._emitAcceleratorTriggered(action, device, timestamp, false);
}
_grabAcceleratorForSender(accelerator, modeFlags, grabFlags, sender) {
@ -337,6 +601,10 @@ export class GnomeShell {
if (sender === name)
this._ungrabAccelerator(action);
}
for (let [session, [sender, data_]] of this._accelerators) {
if (sender === name)
this._accelerators.delete(session);
}
Gio.bus_unwatch_name(this._grabbers.get(name));
this._grabbers.delete(name);
}