-
-
Notifications
You must be signed in to change notification settings - Fork 27
/
Copy pathDaemon.js
627 lines (508 loc) · 23.8 KB
/
Daemon.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
//////////////////////////////////////////////////////////////////////////////////////////
// ___ _ ___ //
// | | \/ | ) | | //
// O- |- | | - | | |- -O //
// | |_ | | | |_ //
// //
//////////////////////////////////////////////////////////////////////////////////////////
// SPDX-FileCopyrightText: Simon Schneegans <[email protected]>
// SPDX-License-Identifier: MIT
'use strict';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Cairo from 'gi://cairo';
import GdkPixbuf from 'gi://GdkPixbuf';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {Source, Notification} from 'resource:///org/gnome/shell/ui/messageTray.js';
import * as utils from '../common/utils.js';
import Statistics from '../common/Statistics.js';
import {Achievements} from '../common/Achievements.js';
import {ItemRegistry} from '../common/ItemRegistry.js';
import {DBusInterface} from '../common/DBusInterface.js';
import {Shortcuts} from './Shortcuts.js';
import TouchButtons from './TouchButtons.js';
import {MouseHighlight} from './MouseHighlight.js';
import Menu from './Menu.js';
import DefaultMenu from './DefaultMenu.js';
import ClipboardManager from './ClipboardManager.js';
const _ = await utils.importGettext();
//////////////////////////////////////////////////////////////////////////////////////////
// The daemon listens on the D-Bus for show-menu requests and registers a global //
// shortcut for each configured menu. For details on the D-Bus interface refer to //
// the README.md. As soon as a valid request is received or a shortcut is pressed, an //
// menu is shown or updated accordingly. //
//////////////////////////////////////////////////////////////////////////////////////////
export default class Daemon {
// ------------------------------------------------------------ constructor / destructor
constructor(metadata) {
this._metadata = metadata;
// Load all of Fly-Pie's resources.
this._resources =
Gio.Resource.load(this._metadata.path + '/resources/flypie.gresource');
Gio.resources_register(this._resources);
// Make the ShowMenu(), PreviewMenu(), ShowCustomMenu(), and the PreviewCustomMenu()
// methods available on the D-Bus.
this._dbus = Gio.DBusExportedObject.wrapJSObject(DBusInterface.description, this);
this._dbus.export(Gio.DBus.session, '/org/gnome/shell/extensions/flypie');
// Enable animations even if no hardware acceleration is available.
if (global.backend && !global.backend.is_rendering_hardware_accelerated()) {
St.Settings.get().uninhibit_animations();
}
// Create the clipboard manager singleton. This is used by the clipboard menu.
ClipboardManager.getInstance();
// Create a settings object and listen for menu configuration changes. Once the
// configuration changes, we bind all the configured shortcuts.
this._settings = utils.createSettings();
// We keep several connections to the Gio.Settings object. Once the extension is
// unloaded, we use this array to disconnect all of them.
this._settingsConnections = [];
// Initialize the menu. For performance reasons the same menu is used again and again.
// It is just reconfigured according to incoming requests.
this._menu = new Menu(
this._settings,
// This gets called whenever the user starts hovering an action in point-and-click
// mode or starts dragging an action in marking mode. It emits the OnHover signal
// of our D-Bus interface.
(menuID, itemID) => this._onHover(menuID, itemID),
// This gets called whenever the user stops hovering an action in point-and-click
// mode or stops dragging an action in marking mode. It emits the OnUnhover signal
// of our D-Bus interface.
(menuID, itemID) => this._onUnhover(menuID, itemID),
// Called when the user selects an item in the menu. This calls the OnSelect
// signal of the DBusInterface.
(menuID, itemID) => this._onSelect(menuID, itemID),
// Called when the user does no select anything in the menu. This calls the
// OnCancel signal of the DBusInterface.
(menuID) => this._onCancel(menuID));
// This is increased once for every menu request.
this._lastMenuID = 0;
// This class manages the global shortcuts. Once one of the registered shortcuts is
// pressed, the corresponding menu is shown via the ShowMenu() method. If an error
// occurred, a notification is shown.
this._shortcuts = new Shortcuts();
// This is called further below. It opens the preconfigured menu with the given name.
const showMenu = (name) => {
const result = this.ShowMenu(name);
if (result < 0) {
utils.debug(
'Failed to open a Fly-Pie menu: ' +
DBusInterface.getErrorDescription(result));
}
};
// Open a menu when the corresponding shortcut is pressed.
this._shortcuts.connect('activated', (s, shortcut) => {
for (let i = 0; i < this._menuConfigs.length; i++) {
if (shortcut == this._menuConfigs[i].shortcut) {
showMenu(this._menuConfigs[i].name);
break;
}
}
});
// Open a menu when the Super+RMB combination is pressed and a menu is configured to
// listen to it.
this._shortcuts.connect('super-rmb', () => {
// Do not attempt to open a new menu if one is already opened.
if (this._menu.getID() == null) {
for (let i = 0; i < this._menuConfigs.length; i++) {
if (this._menuConfigs[i].superRMB) {
showMenu(this._menuConfigs[i].name);
// We have a menu bound to Super+RMB, so we have to prevent the normal
// behavior.
return true;
}
}
}
// No menu is bound to Super+RMB, let the event propagate.
return false;
});
// Create the touch buttons.
this._touchButtons = new TouchButtons(this._settings);
// Here we test whether any menus are configured. If the key is completely empty, this
// is considered to be the same as "[]". If no menus are configured, the default
// configuration is loaded.
let json = this._settings.get_string('menu-configuration');
if (json == '') {
json = '[]';
}
// Try to parse the configuration.
try {
const config = JSON.parse(json);
// If it's not an array something is wrong - the next call to
// _onMenuConfigsChanged() will show an error. We load the default menu only if the
// parsed element is an empty array.
if (Array.isArray(config) && config.length == 0) {
this._settings.set_string(
'menu-configuration', JSON.stringify([DefaultMenu.get()]));
}
} catch (error) {
// If parsing fails, we do nothing here - an error will be shown by the next call to
// _onMenuConfigsChanged().
}
// This is called from the handlers below whenever the global hidpi-factor or the
// global resource-scale factor changes.
const onScaleChange = () => {
this._menu.onSettingsChange();
this._touchButtons.onSettingsChange();
this._onScreencastMouseChanged();
};
// Call the above lambda whenever the global hidpi-factor or the global resource-scale
// factor changes.
const ctx = St.ThemeContext.get_for_stage(global.stage);
this._scaleFactorConnection = ctx.connect('notify::scale-factor', onScaleChange);
this._resourceScaleConnection =
global.stage.connect('resource-scale-changed', onScaleChange);
// Whenever settings are changed, we adapt the currently shown menu accordingly.
this._settingsConnections.push(this._settings.connect('change-event', (o, keys) => {
// For historical reasons, all settings of Fly-Pie are included in one schema. This
// is a bit unfortunate, as we cannot easily listen only for appearance changes, as
// all statistics are included in the schema as well. To avoid reconfiguration of
// the menu if a statistics key changes, we have to manual filter here.
if (Statistics.getInstance().containsAnyNonStatsKey(keys)) {
this._menu.onSettingsChange();
this._touchButtons.onSettingsChange(keys);
}
return false;
}));
// Reload the menu configuration when the settings key changes.
this._settingsConnections.push(this._settings.connect(
'changed::menu-configuration', () => this._onMenuConfigsChanged()));
this._onMenuConfigsChanged();
// Show or hide screencast mouse if the corresponding settings key is toggled.
this._settingsConnections.push(this._settings.connect(
'changed::show-screencast-mouse', () => this._onScreencastMouseChanged()));
this._onScreencastMouseChanged();
// Show notifications whenever a level-up occurred. In fact, this will be shown also
// when a level-down happened, but this should only happen rarely...
this._achievements = new Achievements(this._settings);
this._achievements.connect('level-changed', (o, level) => {
this._notify(
// Translators: This is shown in a desktop notifications.
_('Fly-Pie Level Up!'),
// Translators: This is shown in a desktop notifications.
_('You reached level %i!').replace('%i', level),
GdkPixbuf.Pixbuf.new_from_resource(`/img/levels/level${level}.png`));
});
// Show notifications whenever achievements are unlocked.
this._achievements.connect('completed', (o, id) => {
const achievement = o.getAchievements().get(id);
// Create an icon for the achievement notification.
const surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, 64, 64);
const ctx = new Cairo.Context(surface);
Achievements.paintAchievementIcon(ctx, achievement);
const icon = St.TextureCache.get_default().load_cairo_surface_to_gicon(surface);
// Explicitly tell Cairo to free the context memory.
// https://wiki.gnome.org/Projects/GnomeShell/Extensions/TipsOnMemoryManagement#Cairo
ctx.$dispose();
this._notify(
// Translators: This is shown in a desktop notifications.
_('Fly-Pie Achievement Completed!'),
// Translators: This is shown in a desktop notifications.
_('You finished the achievement "%s"!').replace('%s', achievement.name), icon);
// Whenever an achievement is unlocked, this counter is increased by one. It is used
// to show a small badge in the achievements dialog containing the number of newly
// achieved achievements.
const key = 'stats-unread-achievements';
this._settings.set_uint(key, this._settings.get_uint(key) + 1);
});
}
// Cleans up stuff which is not cleaned up automatically.
destroy() {
// Disable animations again (if no hardware acceleration was available).
if (global.backend && !global.backend.is_rendering_hardware_accelerated()) {
St.Settings.get().inhibit_animations();
}
// Delete the clipboard manager singleton. This is used by the clipboard menu.
ClipboardManager.destroyInstance();
// Delete the touch buttons.
this._touchButtons.destroy();
this._menu.destroy();
this._dbus.flush();
this._dbus.unexport();
this._shortcuts.destroy();
this._settingsConnections.forEach(connection => {
this._settings.disconnect(connection);
});
this._achievements.destroy();
// Hide the screencast mouse pointer (if any).
if (this._screencastMouse) {
this._screencastMouse.destroy();
global.stage.remove_child(this._screencastMouse);
}
// Delete the statistics singleton.
Statistics.destroyInstance();
// Unregister our resources.
Gio.resources_unregister(this._resources);
// Disconnect some handlers.
global.stage.disconnect(this._resourceScaleConnection);
St.ThemeContext.get_for_stage(global.stage).disconnect(this._scaleFactorConnection);
}
// -------------------------------------------------------------- public D-Bus-Interface
// This opens a menu configured with Fly-Pie's menu editor and can be directly called
// over the D-Bus. See the README.md for a description of Fly-Pie's DBusInterface. If
// there are more than one menu with the same name, the first will be opened.
ShowMenu(name) {
return this.ShowMenuAt(name, null, null);
}
// Same as above, but instead at the pointer location, the menu will be opened at the
// given pixel coordinates.
ShowMenuAt(name, x, y) {
return this._openMenu(name, false, x, y);
}
// Opens a menu with that name if there are currently none open. Closes the currently
// open menu otherwise.
ToggleMenu(name) {
if (this._menu.getID() == null) {
return this.ShowMenu(name);
}
this.CancelMenu();
return DBusInterface.errorCodes.eHadToCancelAMenu;
}
// This opens a menu configured with Fly-Pie's menu editor in preview mode and can be
// directly called over the D-Bus. See the README.md for a description of Fly-Pie's
// DBusInterface. If there are more than one menu with the same name, the first will be
// opened.
PreviewMenu(name) {
return this._openMenu(name, true, null, null);
}
// This opens a custom menu and can be directly called over the D-Bus.
// See the README.md for a description of Fly-Pie's DBusInterface.
ShowCustomMenu(json) {
return this.ShowCustomMenuAt(json, null, null);
}
// Same as above, but instead at the pointer location, the menu will be opened at the
// given pixel coordinates.
ShowCustomMenuAt(json, x, y) {
this._lastMenuID = this._getNextMenuID(this._lastMenuID);
Statistics.getInstance().addCustomDBusMenu();
return this._openCustomMenu(json, false, this._lastMenuID, x, y);
}
// This opens a custom menu in preview mode and can be directly called over the D-Bus.
// See the README.md for a description of Fly-Pie's DBusInterface.
PreviewCustomMenu(json) {
this._lastMenuID = this._getNextMenuID(this._lastMenuID);
Statistics.getInstance().addCustomDBusMenu();
return this._openCustomMenu(json, true, this._lastMenuID, null, null);
}
// This closes the currently open menu (if any).
CancelMenu() {
if (this._menu.getID() == null) {
return DBusInterface.errorCodes.eNoActiveMenu;
}
this._menu.cancel();
this._menu.close();
return 0;
}
// This selects an item in the currently opened menu.
// See the README.md for a description of Fly-Pie's DBusInterface.
SelectItem(path) {
return this._menu.selectItem(path);
}
// ----------------------------------------------------------------------- private stuff
// Opens a menu configured with Fly-Pie's menu editor, optionally in preview mode. The
// menu's name must be given as parameter. It will return a positive number on success
// and a negative on failure. See common/DBusInterface.js for a list of error codes.
_openMenu(name, previewMode, x, y) {
// Search for the meu with the given name.
for (let i = 0; i < this._menuConfigs.length; i++) {
if (name == this._menuConfigs[i].name) {
// Once we found the desired menu, we can open the menu with the custom-menu
// method.
return this._openCustomMenu(
this._menuConfigs[i], previewMode, this._menuConfigs[i].id, x, y);
}
}
// There is no menu with such a name.
return DBusInterface.errorCodes.eNoSuchMenu;
}
// Open the menu described by 'config', optionally in preview mode. 'config' can either
// be a JSON string or an object containing the menu configuration. This method will
// return the menu's ID on success or an error code on failure. See
// common/DBusInterface.js for a list of error codes.
_openCustomMenu(config, previewMode, menuID, x, y) {
// First try to parse the menu configuration if it's given as a json string.
if (typeof config === 'string') {
try {
config = JSON.parse(config);
} catch (error) {
utils.debug('Failed to parse menu configuration JSON: ' + error);
return DBusInterface.errorCodes.eInvalidJSON;
}
}
// Then normalize the menu configuration (e.g. add all default data).
try {
ItemRegistry.normalizeConfig(config);
} catch (error) {
utils.debug('Failed to parse menu configuration: ' + error);
return DBusInterface.errorCodes.eInvalidMenuConfiguration;
}
// Then try to transform the menu configuration to a menu structure. See
// ItemRegistry.js for details.
let structure;
try {
structure = ItemRegistry.transformConfig(config);
} catch (error) {
utils.debug('Failed to transform menu configuration: ' + error);
return DBusInterface.errorCodes.eInvalidMenuConfiguration;
}
// Then try to open the menu. This will return the menu's ID on success or an error
// code on failure.
try {
return this._menu.open(menuID, structure, previewMode, x, y);
} catch (error) {
utils.debug('Failed to show menu: ' + error);
}
// Something weird happened.
return DBusInterface.errorCodes.eUnknownError;
}
// This gets called whenever the user starts hovering an action in point-and-click
// mode or starts dragging an action in marking mode. It emits the OnHover signal of
// our D-Bus interface.
_onHover(menuID, itemID) {
this._dbus.emit_signal('OnHover', GLib.Variant.new('(is)', [menuID, itemID]));
}
// This gets called whenever the user stops hovering an action in point-and-click
// mode or stops dragging an action in marking mode. It emits the OnUnhover signal of
// our D-Bus interface.
_onUnhover(menuID, itemID) {
this._dbus.emit_signal('OnUnhover', GLib.Variant.new('(is)', [menuID, itemID]));
}
// This gets called once the user made a selection in the menu. It emit the OnSelect
// signal of our D-Bus interface.
_onSelect(menuID, itemID) {
this._dbus.emit_signal('OnSelect', GLib.Variant.new('(is)', [menuID, itemID]));
}
// This gets called when the user did not select anything in the menu. It emits the
// OnCancel signal of our D-Bus interface.
_onCancel(menuID) {
Statistics.getInstance().addAbortion();
this._dbus.emit_signal('OnCancel', GLib.Variant.new('(i)', [menuID]));
}
// Whenever the menu configuration changes, we check for any new shortcuts which need to
// be bound.
_onMenuConfigsChanged() {
// Try to load the new menu configuration.
try {
this._menuConfigs = JSON.parse(this._settings.get_string('menu-configuration'));
} catch (error) {
utils.debug('Failed to load Fly-Pie menu configuration: ' + error);
this._menuConfigs = [];
}
// Root element must be an array of menus.
if (!Array.isArray(this._menuConfigs)) {
utils.debug(
'Failed to load Fly-Pie menu configuration: Root element must be an array!');
this._menuConfigs = [];
}
// Update touch buttons --------------------------------------------------------------
this._touchButtons.setMenuConfigs(this._menuConfigs);
// Update currently bound global shortcuts -------------------------------------------
// First we create a set of all required shortcuts.
const newShortcuts = new Set();
for (let i = 0; i < this._menuConfigs.length; i++) {
if (this._menuConfigs[i].shortcut) {
newShortcuts.add(this._menuConfigs[i].shortcut);
}
}
// Then we iterate over all currently bound shortcuts and unbind the ones which are
// not required anymore and remove the one which are already bound from the set of
// required shortcuts.
for (let existingShortcut of this._shortcuts.getBound()) {
if (newShortcuts.has(existingShortcut)) {
newShortcuts.delete(existingShortcut);
} else {
this._shortcuts.unbind(existingShortcut);
}
}
// Finally, we bind any remaining shortcuts from our set.
for (let requiredShortcut of newShortcuts) {
this._shortcuts.bind(requiredShortcut);
}
// Update currently shown menu (if any) ----------------------------------------------
// There is currently a menu open, so we potentially have to update the displayed
// menu.
if (this._menu.getID() != null) {
for (let i = 0; i < this._menuConfigs.length; i++) {
if (this._menuConfigs[i].id == this._menu.getID()) {
// Transform the configuration into a menu structure.
ItemRegistry.normalizeConfig(this._menuConfigs[i]);
const structure = ItemRegistry.transformConfig(this._menuConfigs[i]);
// Once we transformed the menu configuration to a menu structure, we can update
// the menu with the new structure.
const result = this._menu.update(structure);
// If that was successful, we are done.
if (result >= 0) {
return;
}
break;
}
}
// Something went wrong in updating the preview. Let's hide it.
this._onCancel(this._menu.getID());
this._menu.close();
}
}
// This returns a new ID for a custom show-menu request. The last ID is increased by, if
// the result collides with an ID of a menu configured with Fly-Pie's menu editor, it
// is increased once more.
_getNextMenuID(lastID) {
let nextID = lastID;
let isInUse = false;
do {
++nextID;
isInUse = false;
// Check whether this ID is in use.
for (let i = 0; i < this._menuConfigs.length; i++) {
if (this._menuConfigs[i].id == nextID) {
isInUse = true;
}
}
} while (isInUse);
return nextID;
}
// This enables / disables the additional mouse pointer for screencasts. It is simply
// created as a child of the global.stage.
_onScreencastMouseChanged() {
if (this._screencastMouse) {
this._screencastMouse.destroy();
delete this._screencastMouse;
}
if (this._settings.get_boolean('show-screencast-mouse')) {
// For now, we use a hard-coded size of 50. This can be made configurable in the
// future if anybody needs it.
const size = 50 * utils.getHDPIScale();
this._screencastMouse = new MouseHighlight(size);
global.stage.add_child(this._screencastMouse);
}
}
// Shows a GNOME Shell notification with the given label, description and icon. The size
// of the icon seems to depend on the currently used theme and cannot be set from here.
// The notification will also contain a hard-coded button which opens the achievements
// page of the settings dialog.
_notify(title, body, gicon) {
if (this._settings.get_boolean('achievement-notifications')) {
let source, notification;
if (utils.shellVersionIsAtLeast(46)) {
source = new Source({title: 'Fly-Pie'});
notification = new Notification({source, title, body, gicon: gicon});
} else {
source = new Source('Fly-Pie', '');
notification = new Notification(source, title, body, {gicon: gicon});
}
Main.messageTray.add(source);
// Translators: This is shown on the action button of the notification bubble which
// is shown once an achievement is unlocked.
notification.addAction(_('Show Achievements'), () => {
// Make sure the achievements page is shown.
this._settings.set_string('active-stack-child', 'achievements-page');
// Show the settings dialog.
Main.extensionManager.openExtensionPrefs(this._metadata.uuid, '');
});
if (source.addNotification) {
source.addNotification(notification);
} else {
source.showNotification(notification);
}
}
}
};