This commit is contained in:
2024-07-08 22:46:35 +02:00
parent 02f44c49d2
commit 27254d817a
56249 changed files with 808097 additions and 1 deletions

View File

@ -0,0 +1,312 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Atspi from 'gi://Atspi?version=2.0';
import Gdk from 'gi://Gdk';
/**
* Printable ASCII range
*/
const _ASCII = /[\x20-\x7E]/;
/**
* Modifier Keycode Defaults
*/
const XKeycode = {
Alt_L: 0x40,
Control_L: 0x25,
Shift_L: 0x32,
Super_L: 0x85,
};
/**
* A thin wrapper around Atspi for X11 sessions without Pipewire support.
*/
export default class Controller {
constructor() {
// Atspi.init() return 2 on fail, but still marks itself as inited. We
// uninit before throwing an error otherwise any future call to init()
// will appear successful and other calls will cause GSConnect to exit.
// See: https://gitlab.gnome.org/GNOME/at-spi2-core/blob/master/atspi/atspi-misc.c
if (Atspi.init() === 2) {
this.destroy();
throw new Error('Failed to start AT-SPI');
}
try {
this._display = Gdk.Display.get_default();
this._seat = this._display.get_default_seat();
this._pointer = this._seat.get_pointer();
} catch (e) {
this.destroy();
throw e;
}
// Try to read modifier keycodes from Gdk
try {
const keymap = Gdk.Keymap.get_for_display(this._display);
let modifier;
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Alt_L)[1][0];
XKeycode.Alt_L = modifier.keycode;
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Control_L)[1][0];
XKeycode.Control_L = modifier.keycode;
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Shift_L)[1][0];
XKeycode.Shift_L = modifier.keycode;
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Super_L)[1][0];
XKeycode.Super_L = modifier.keycode;
} catch (e) {
debug('using default modifier keycodes');
}
}
/*
* Pointer events
*/
clickPointer(button) {
try {
const [, x, y] = this._pointer.get_position();
const monitor = this._display.get_monitor_at_point(x, y);
const scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}c`);
} catch (e) {
logError(e);
}
}
doubleclickPointer(button) {
try {
const [, x, y] = this._pointer.get_position();
const monitor = this._display.get_monitor_at_point(x, y);
const scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}d`);
} catch (e) {
logError(e);
}
}
movePointer(dx, dy) {
try {
const [, x, y] = this._pointer.get_position();
const monitor = this._display.get_monitor_at_point(x, y);
const scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * dx, scale * dy, 'rel');
} catch (e) {
logError(e);
}
}
pressPointer(button) {
try {
const [, x, y] = this._pointer.get_position();
const monitor = this._display.get_monitor_at_point(x, y);
const scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}p`);
} catch (e) {
logError(e);
}
}
releasePointer(button) {
try {
const [, x, y] = this._pointer.get_position();
const monitor = this._display.get_monitor_at_point(x, y);
const scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}r`);
} catch (e) {
logError(e);
}
}
scrollPointer(dx, dy) {
if (dy > 0)
this.clickPointer(4);
else if (dy < 0)
this.clickPointer(5);
}
/*
* Phony virtual keyboard helpers
*/
_modeLock(keycode) {
Atspi.generate_keyboard_event(
keycode,
null,
Atspi.KeySynthType.PRESS
);
}
_modeUnlock(keycode) {
Atspi.generate_keyboard_event(
keycode,
null,
Atspi.KeySynthType.RELEASE
);
}
/*
* Simulate a printable-ASCII character.
*
*/
_pressASCII(key, modifiers) {
try {
// Press Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this._modeLock(XKeycode.Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this._modeLock(XKeycode.Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this._modeLock(XKeycode.Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this._modeLock(XKeycode.Super_L);
Atspi.generate_keyboard_event(
0,
key,
Atspi.KeySynthType.STRING
);
// Release Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this._modeUnlock(XKeycode.Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this._modeUnlock(XKeycode.Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this._modeUnlock(XKeycode.Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this._modeUnlock(XKeycode.Super_L);
} catch (e) {
logError(e);
}
}
_pressKeysym(keysym, modifiers) {
try {
// Press Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this._modeLock(XKeycode.Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this._modeLock(XKeycode.Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this._modeLock(XKeycode.Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this._modeLock(XKeycode.Super_L);
Atspi.generate_keyboard_event(
keysym,
null,
Atspi.KeySynthType.PRESSRELEASE | Atspi.KeySynthType.SYM
);
// Release Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this._modeUnlock(XKeycode.Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this._modeUnlock(XKeycode.Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this._modeUnlock(XKeycode.Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this._modeUnlock(XKeycode.Super_L);
} catch (e) {
logError(e);
}
}
/**
* Simulate the composition of a unicode character with:
* Control+Shift+u, [hex], Return
*
* @param {number} key - An XKeycode
* @param {number} modifiers - A modifier mask
*/
_pressUnicode(key, modifiers) {
try {
if (modifiers > 0)
log('GSConnect: ignoring modifiers for unicode keyboard event');
// TODO: Using Control and Shift keysym is not working (it triggers
// key release). Probably using LOCKMODIFIERS will not work either
// as unlocking the modifier will not trigger a release
// Activate compose sequence
this._modeLock(XKeycode.Control_L);
this._modeLock(XKeycode.Shift_L);
this.pressreleaseKeysym(Gdk.KEY_U);
this._modeUnlock(XKeycode.Control_L);
this._modeUnlock(XKeycode.Shift_L);
// Enter the unicode sequence
const ucode = key.charCodeAt(0).toString(16);
let keysym;
for (let h = 0, len = ucode.length; h < len; h++) {
keysym = Gdk.unicode_to_keyval(ucode.charAt(h).codePointAt(0));
this.pressreleaseKeysym(keysym);
}
// Finish the compose sequence
this.pressreleaseKeysym(Gdk.KEY_Return);
} catch (e) {
logError(e);
}
}
/*
* Keyboard Events
*/
pressKeysym(keysym) {
Atspi.generate_keyboard_event(
keysym,
null,
Atspi.KeySynthType.PRESS | Atspi.KeySynthType.SYM
);
}
releaseKeysym(keysym) {
Atspi.generate_keyboard_event(
keysym,
null,
Atspi.KeySynthType.RELEASE | Atspi.KeySynthType.SYM
);
}
pressreleaseKeysym(keysym) {
Atspi.generate_keyboard_event(
keysym,
null,
Atspi.KeySynthType.PRESSRELEASE | Atspi.KeySynthType.SYM
);
}
pressKey(input, modifiers) {
// We were passed a keysym
if (typeof input === 'number')
this._pressKeysym(input, modifiers);
// Regular ASCII
else if (_ASCII.test(input))
this._pressASCII(input, modifiers);
// Unicode
else
this._pressUnicode(input, modifiers);
}
destroy() {
try {
Atspi.exit();
} catch (e) {
// Silence errors
}
}
}

View File

@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
/**
* The service class for this component
*/
const Clipboard = GObject.registerClass({
GTypeName: 'GSConnectClipboard',
Properties: {
'text': GObject.ParamSpec.string(
'text',
'Text Content',
'The current text content of the clipboard',
GObject.ParamFlags.READWRITE,
''
),
},
}, class Clipboard extends GObject.Object {
_init() {
super._init();
this._cancellable = new Gio.Cancellable();
this._clipboard = null;
this._ownerChangeId = 0;
this._nameWatcherId = Gio.bus_watch_name(
Gio.BusType.SESSION,
DBUS_NAME,
Gio.BusNameWatcherFlags.NONE,
this._onNameAppeared.bind(this),
this._onNameVanished.bind(this)
);
}
get text() {
if (this._text === undefined)
this._text = '';
return this._text;
}
set text(content) {
if (this.text === content)
return;
this._text = content;
this.notify('text');
if (typeof content !== 'string')
return;
if (this._clipboard instanceof Gtk.Clipboard)
this._clipboard.set_text(content, -1);
if (this._clipboard instanceof Gio.DBusProxy) {
this._clipboard.call('SetText', new GLib.Variant('(s)', [content]),
Gio.DBusCallFlags.NO_AUTO_START, -1, this._cancellable)
.catch(debug);
}
}
async _onNameAppeared(connection, name, name_owner) {
try {
// Cleanup the GtkClipboard
if (this._clipboard && this._ownerChangeId > 0) {
this._clipboard.disconnect(this._ownerChangeId);
this._ownerChangeId = 0;
}
// Create a proxy for the remote clipboard
this._clipboard = new Gio.DBusProxy({
g_bus_type: Gio.BusType.SESSION,
g_name: DBUS_NAME,
g_object_path: DBUS_PATH,
g_interface_name: DBUS_NAME,
g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES,
});
await this._clipboard.init_async(GLib.PRIORITY_DEFAULT,
this._cancellable);
this._ownerChangeId = this._clipboard.connect('g-signal',
this._onOwnerChange.bind(this));
this._onOwnerChange();
if (!globalThis.HAVE_GNOME) {
// Directly subscrible signal
this.signalHandler = Gio.DBus.session.signal_subscribe(
DBUS_NAME,
DBUS_NAME,
'OwnerChange',
DBUS_PATH,
null,
Gio.DBusSignalFlags.NONE,
this._onOwnerChange.bind(this)
);
}
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
debug(e);
this._onNameVanished(null, null);
}
}
}
_onNameVanished(connection, name) {
if (this._clipboard && this._ownerChangeId > 0) {
this._clipboard.disconnect(this._ownerChangeId);
this._clipboardChangedId = 0;
}
const display = Gdk.Display.get_default();
this._clipboard = Gtk.Clipboard.get_default(display);
this._ownerChangeId = this._clipboard.connect('owner-change',
this._onOwnerChange.bind(this));
this._onOwnerChange();
}
async _onOwnerChange() {
try {
if (this._clipboard instanceof Gtk.Clipboard)
await this._gtkUpdateText();
else if (this._clipboard instanceof Gio.DBusProxy)
await this._proxyUpdateText();
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
debug(e);
}
}
_applyUpdate(text) {
if (typeof text !== 'string' || this.text === text)
return;
this._text = text;
this.notify('text');
}
/*
* Proxy Clipboard
*/
async _proxyUpdateText() {
let reply = await this._clipboard.call('GetMimetypes', null,
Gio.DBusCallFlags.NO_AUTO_START, -1, this._cancellable);
const mimetypes = reply.deepUnpack()[0];
// Special case for a cleared clipboard
if (mimetypes.length === 0)
return this._applyUpdate('');
// Special case to ignore copied files
if (mimetypes.includes('text/uri-list'))
return;
reply = await this._clipboard.call('GetText', null,
Gio.DBusCallFlags.NO_AUTO_START, -1, this._cancellable);
const text = reply.deepUnpack()[0];
this._applyUpdate(text);
}
/*
* GtkClipboard
*/
async _gtkUpdateText() {
const mimetypes = await new Promise((resolve, reject) => {
this._clipboard.request_targets((clipboard, atoms) => resolve(atoms));
});
// Special case for a cleared clipboard
if (mimetypes.length === 0)
return this._applyUpdate('');
// Special case to ignore copied files
if (mimetypes.includes('text/uri-list'))
return;
const text = await new Promise((resolve, reject) => {
this._clipboard.request_text((clipboard, text) => resolve(text));
});
this._applyUpdate(text);
}
destroy() {
if (this._cancellable.is_cancelled())
return;
this._cancellable.cancel();
if (this._clipboard && this._ownerChangeId > 0) {
this._clipboard.disconnect(this._ownerChangeId);
this._ownerChangedId = 0;
}
if (this._nameWatcherId > 0) {
Gio.bus_unwatch_name(this._nameWatcherId);
this._nameWatcherId = 0;
}
if (!globalThis.HAVE_GNOME && this.signalHandler)
Gio.DBus.session.signal_unsubscribe(this.signalHandler);
}
});
export default Clipboard;
// vim:tabstop=2:shiftwidth=2:expandtab

View File

@ -0,0 +1,613 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
let HAVE_EDS = true;
let EBook = null;
let EBookContacts = null;
let EDataServer = null;
try {
EBook = (await import('gi://EBook')).default;
EBookContacts = (await import('gi://EBookContacts')).default;
EDataServer = (await import('gi://EDataServer')).default;
} catch (e) {
HAVE_EDS = false;
}
/**
* A store for contacts
*/
const Store = GObject.registerClass({
GTypeName: 'GSConnectContactsStore',
Properties: {
'context': GObject.ParamSpec.string(
'context',
'Context',
'Used as the cache directory, relative to Config.CACHEDIR',
GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE,
null
),
},
Signals: {
'contact-added': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_STRING],
},
'contact-removed': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_STRING],
},
'contact-changed': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_STRING],
},
},
}, class Store extends GObject.Object {
_init(context = null) {
super._init({
context: context,
});
this._cacheData = {};
this._edsPrepared = false;
}
/**
* Parse an EContact and add it to the store.
*
* @param {EBookContacts.Contact} econtact - an EContact to parse
* @param {string} [origin] - an optional origin string
*/
async _parseEContact(econtact, origin = 'desktop') {
try {
const contact = {
id: econtact.id,
name: _('Unknown Contact'),
numbers: [],
origin: origin,
timestamp: 0,
};
// Try to get a contact name
if (econtact.full_name)
contact.name = econtact.full_name;
// Parse phone numbers
const nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
for (const attr of nums) {
const number = {
value: attr.get_value(),
type: 'unknown',
};
if (attr.has_type('CELL'))
number.type = 'cell';
else if (attr.has_type('HOME'))
number.type = 'home';
else if (attr.has_type('WORK'))
number.type = 'work';
contact.numbers.push(number);
}
// Try and get a contact photo
const photo = econtact.photo;
if (photo) {
if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
const data = photo.get_inlined()[0];
contact.avatar = await this.storeAvatar(data);
} else if (photo.type === EBookContacts.ContactPhotoType.URI) {
const uri = econtact.photo.get_uri();
contact.avatar = uri.replace('file://', '');
}
}
this.add(contact, false);
} catch (e) {
logError(e, `Failed to parse VCard contact ${econtact.id}`);
}
}
/*
* AddressBook DBus callbacks
*/
_onObjectsAdded(connection, sender, path, iface, signal, params) {
try {
const adds = params.get_child_value(0).get_strv();
// NOTE: sequential pairs of vcard, id
for (let i = 0, len = adds.length; i < len; i += 2) {
try {
const vcard = adds[i];
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
this._parseEContact(econtact);
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
_onObjectsRemoved(connection, sender, path, iface, signal, params) {
try {
const changes = params.get_child_value(0).get_strv();
for (const id of changes) {
try {
this.remove(id, false);
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
_onObjectsModified(connection, sender, path, iface, signal, params) {
try {
const changes = params.get_child_value(0).get_strv();
// NOTE: sequential pairs of vcard, id
for (let i = 0, len = changes.length; i < len; i += 2) {
try {
const vcard = changes[i];
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
this._parseEContact(econtact);
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
/*
* SourceRegistryWatcher callbacks
*/
async _onAppeared(watcher, source) {
try {
// Get an EBookClient and EBookView
const uid = source.get_uid();
const client = await EBook.BookClient.connect(source, null);
const [view] = await client.get_view('exists "tel"', null);
// Watch the view for changes to the address book
const connection = view.get_connection();
const objectPath = view.get_object_path();
view._objectsAddedId = connection.signal_subscribe(
null,
'org.gnome.evolution.dataserver.AddressBookView',
'ObjectsAdded',
objectPath,
null,
Gio.DBusSignalFlags.NONE,
this._onObjectsAdded.bind(this)
);
view._objectsRemovedId = connection.signal_subscribe(
null,
'org.gnome.evolution.dataserver.AddressBookView',
'ObjectsRemoved',
objectPath,
null,
Gio.DBusSignalFlags.NONE,
this._onObjectsRemoved.bind(this)
);
view._objectsModifiedId = connection.signal_subscribe(
null,
'org.gnome.evolution.dataserver.AddressBookView',
'ObjectsModified',
objectPath,
null,
Gio.DBusSignalFlags.NONE,
this._onObjectsModified.bind(this)
);
view.start();
// Store the EBook in a map
this._ebooks.set(uid, {
source: source,
client: client,
view: view,
});
} catch (e) {
debug(e);
}
}
_onDisappeared(watcher, source) {
try {
const uid = source.get_uid();
const ebook = this._ebooks.get(uid);
if (ebook === undefined)
return;
// Disconnect the EBookView
if (ebook.view) {
const connection = ebook.view.get_connection();
connection.signal_unsubscribe(ebook.view._objectsAddedId);
connection.signal_unsubscribe(ebook.view._objectsRemovedId);
connection.signal_unsubscribe(ebook.view._objectsModifiedId);
ebook.view.stop();
}
this._ebooks.delete(uid);
} catch (e) {
debug(e);
}
}
async _initEvolutionDataServer() {
try {
if (this._edsPrepared)
return;
this._edsPrepared = true;
this._ebooks = new Map();
// Get the current EBooks
const registry = await this._getESourceRegistry();
for (const source of registry.list_sources('Address Book'))
await this._onAppeared(null, source);
// Watch for new and removed sources
this._watcher = new EDataServer.SourceRegistryWatcher({
registry: registry,
extension_name: 'Address Book',
});
this._appearedId = this._watcher.connect(
'appeared',
this._onAppeared.bind(this)
);
this._disappearedId = this._watcher.connect(
'disappeared',
this._onDisappeared.bind(this)
);
} catch (e) {
const service = Gio.Application.get_default();
if (service !== null)
service.notify_error(e);
else
logError(e);
}
}
*[Symbol.iterator]() {
const contacts = Object.values(this._cacheData);
for (let i = 0, len = contacts.length; i < len; i++)
yield contacts[i];
}
get contacts() {
return Object.values(this._cacheData);
}
get context() {
if (this._context === undefined)
this._context = null;
return this._context;
}
set context(context) {
this._context = context;
this._cacheDir = Gio.File.new_for_path(Config.CACHEDIR);
if (context !== null)
this._cacheDir = this._cacheDir.get_child(context);
GLib.mkdir_with_parents(this._cacheDir.get_path(), 448);
this._cacheFile = this._cacheDir.get_child('contacts.json');
}
/**
* Save a Uint8Array to file and return the path
*
* @param {Uint8Array} contents - An image byte array
* @return {string|undefined} File path or %undefined on failure
*/
async storeAvatar(contents) {
const md5 = GLib.compute_checksum_for_data(GLib.ChecksumType.MD5,
contents);
const file = this._cacheDir.get_child(`${md5}`);
if (!file.query_exists(null)) {
try {
await file.replace_contents_bytes_async(
new GLib.Bytes(contents),
null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
} catch (e) {
debug(e, 'Storing avatar');
return undefined;
}
}
return file.get_path();
}
/**
* Query the Store for a contact by name and/or number.
*
* @param {Object} query - A query object
* @param {string} [query.name] - The contact's name
* @param {string} query.number - The contact's number
* @return {Object} A contact object
*/
query(query) {
// First look for an existing contact by number
const contacts = this.contacts;
const matches = [];
const qnumber = query.number.toPhoneNumber();
for (let i = 0, len = contacts.length; i < len; i++) {
const contact = contacts[i];
for (const num of contact.numbers) {
const cnumber = num.value.toPhoneNumber();
if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) {
// If no query name or exact match, return immediately
if (!query.name || query.name === contact.name)
return contact;
// Otherwise we might find an exact name match that shares
// the number with another contact
matches.push(contact);
}
}
}
// Return the first match (pretty much what Android does)
if (matches.length > 0)
return matches[0];
// No match; return a mock contact with a unique ID
let id = GLib.uuid_string_random();
while (this._cacheData.hasOwnProperty(id))
id = GLib.uuid_string_random();
return {
id: id,
name: query.name || query.number,
numbers: [{value: query.number, type: 'unknown'}],
origin: 'gsconnect',
};
}
get_contact(position) {
if (this._cacheData[position] !== undefined)
return this._cacheData[position];
return null;
}
/**
* Add a contact, checking for validity
*
* @param {Object} contact - A contact object
* @param {boolean} write - Write to disk
*/
add(contact, write = true) {
// Ensure the contact has a unique id
if (!contact.id) {
let id = GLib.uuid_string_random();
while (this._cacheData[id])
id = GLib.uuid_string_random();
contact.id = id;
}
// Ensure the contact has an origin
if (!contact.origin)
contact.origin = 'gsconnect';
// This is an updated contact
if (this._cacheData[contact.id]) {
this._cacheData[contact.id] = contact;
this.emit('contact-changed', contact.id);
// This is a new contact
} else {
this._cacheData[contact.id] = contact;
this.emit('contact-added', contact.id);
}
// Write if requested
if (write)
this.save();
}
/**
* Remove a contact by id
*
* @param {string} id - The id of the contact to delete
* @param {boolean} write - Write to disk
*/
remove(id, write = true) {
// Only remove if the contact actually exists
if (this._cacheData[id]) {
delete this._cacheData[id];
this.emit('contact-removed', id);
// Write if requested
if (write)
this.save();
}
}
/**
* Lookup a contact for each address object in @addresses and return a
* dictionary of address (eg. phone number) to contact object.
*
* { "555-5555": { "name": "...", "numbers": [], ... } }
*
* @param {Object[]} addresses - A list of address objects
* @return {Object} A dictionary of phone numbers and contacts
*/
lookupAddresses(addresses) {
const contacts = {};
// Lookup contacts for each address
for (let i = 0, len = addresses.length; i < len; i++) {
const address = addresses[i].address;
contacts[address] = this.query({
number: address,
});
}
return contacts;
}
async clear() {
try {
const contacts = this.contacts;
for (let i = 0, len = contacts.length; i < len; i++)
await this.remove(contacts[i].id, false);
await this.save();
} catch (e) {
debug(e);
}
}
/**
* Update the contact store from a dictionary of our custom contact objects.
*
* @param {Object} json - an Object of contact Objects
*/
async update(json = {}) {
try {
let contacts = Object.values(json);
for (let i = 0, len = contacts.length; i < len; i++) {
const new_contact = contacts[i];
const contact = this._cacheData[new_contact.id];
if (!contact || new_contact.timestamp !== contact.timestamp)
await this.add(new_contact, false);
}
// Prune contacts
contacts = this.contacts;
for (let i = 0, len = contacts.length; i < len; i++) {
const contact = contacts[i];
if (!json[contact.id])
await this.remove(contact.id, false);
}
await this.save();
} catch (e) {
debug(e, 'Updating contacts');
}
}
/**
* Fetch and update the contact store from its source.
*
* The default function initializes the EDS server, or logs a debug message
* if EDS is unavailable. Derived classes should request an update from the
* remote source.
*/
async fetch() {
try {
if (this.context === null && HAVE_EDS)
await this._initEvolutionDataServer();
else
throw new Error('Evolution Data Server not available');
} catch (e) {
debug(e);
}
}
/**
* Load the contacts from disk.
*/
async load() {
try {
const [contents] = await this._cacheFile.load_contents_async(null);
this._cacheData = JSON.parse(new TextDecoder().decode(contents));
} catch (e) {
debug(e);
} finally {
this.notify('context');
}
}
/**
* Save the contacts to disk.
*/
async save() {
// EDS is handling storage
if (this.context === null && HAVE_EDS)
return;
if (this.__cache_lock) {
this.__cache_queue = true;
return;
}
try {
this.__cache_lock = true;
const contents = new GLib.Bytes(JSON.stringify(this._cacheData, null, 2));
await this._cacheFile.replace_contents_bytes_async(contents, null,
false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
} catch (e) {
debug(e);
} finally {
this.__cache_lock = false;
if (this.__cache_queue) {
this.__cache_queue = false;
this.save();
}
}
}
destroy() {
if (this._watcher !== undefined) {
this._watcher.disconnect(this._appearedId);
this._watcher.disconnect(this._disappearedId);
this._watcher = undefined;
for (const ebook of this._ebooks.values())
this._onDisappeared(null, ebook.source);
this._edsPrepared = false;
}
}
});
export default Store;

View File

@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import * as atspi from './atspi.js';
import * as clipboard from './clipboard.js';
import * as contacts from './contacts.js';
import * as input from './input.js';
import * as mpris from './mpris.js';
import * as notification from './notification.js';
import * as pulseaudio from './pulseaudio.js';
import * as session from './session.js';
import * as sound from './sound.js';
import * as upower from './upower.js';
import * as ydotool from './ydotool.js';
export const functionOverrides = {};
const components = {
atspi,
clipboard,
contacts,
input,
mpris,
notification,
pulseaudio,
session,
sound,
upower,
ydotool,
};
/*
* Singleton Tracker
*/
const Default = new Map();
/**
* Acquire a reference to a component. Calls to this function should always be
* followed by a call to `release()`.
*
* @param {string} name - The module name
* @return {*} The default instance of a component
*/
export function acquire(name) {
if (functionOverrides.acquire)
return functionOverrides.acquire(name);
let component;
try {
let info = Default.get(name);
if (info === undefined) {
const module = components[name];
info = {
instance: new module.default(),
refcount: 0,
};
Default.set(name, info);
}
info.refcount++;
component = info.instance;
} catch (e) {
debug(e, name);
}
return component;
}
/**
* Release a reference on a component. If the caller was the last reference
* holder, the component will be freed.
*
* @param {string} name - The module name
* @return {null} A %null value, useful for overriding a traced variable
*/
export function release(name) {
if (functionOverrides.release)
return functionOverrides.release(name);
try {
const info = Default.get(name);
if (info.refcount === 1) {
info.instance.destroy();
Default.delete(name);
}
info.refcount--;
} catch (e) {
debug(e, name);
}
return null;
}

View File

@ -0,0 +1,514 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import AtspiController from './atspi.js';
const SESSION_TIMEOUT = 15;
const RemoteSession = GObject.registerClass({
GTypeName: 'GSConnectRemoteSession',
Implements: [Gio.DBusInterface],
Signals: {
'closed': {
flags: GObject.SignalFlags.RUN_FIRST,
},
},
}, class RemoteSession extends Gio.DBusProxy {
_init(objectPath) {
super._init({
g_bus_type: Gio.BusType.SESSION,
g_name: 'org.gnome.Mutter.RemoteDesktop',
g_object_path: objectPath,
g_interface_name: 'org.gnome.Mutter.RemoteDesktop.Session',
g_flags: Gio.DBusProxyFlags.NONE,
});
this._started = false;
}
vfunc_g_signal(sender_name, signal_name, parameters) {
if (signal_name === 'Closed')
this.emit('closed');
}
_call(name, parameters = null) {
if (!this._started)
return;
// Pass a null callback to allow this call to finish itself
this.call(name, parameters, Gio.DBusCallFlags.NONE, -1, null, null);
}
get session_id() {
try {
return this.get_cached_property('SessionId').unpack();
} catch (e) {
return null;
}
}
async start() {
try {
if (this._started)
return;
// Initialize the proxy, and start the session
await this.init_async(GLib.PRIORITY_DEFAULT, null);
await this.call('Start', null, Gio.DBusCallFlags.NONE, -1, null);
this._started = true;
} catch (e) {
this.destroy();
Gio.DBusError.strip_remote_error(e);
throw e;
}
}
stop() {
if (this._started) {
this._started = false;
// Pass a null callback to allow this call to finish itself
this.call('Stop', null, Gio.DBusCallFlags.NONE, -1, null, null);
}
}
_translateButton(button) {
switch (button) {
case Gdk.BUTTON_PRIMARY:
return 0x110;
case Gdk.BUTTON_MIDDLE:
return 0x112;
case Gdk.BUTTON_SECONDARY:
return 0x111;
case 4:
return 0; // FIXME
case 5:
return 0x10F; // up
}
}
movePointer(dx, dy) {
this._call(
'NotifyPointerMotionRelative',
GLib.Variant.new('(dd)', [dx, dy])
);
}
pressPointer(button) {
button = this._translateButton(button);
this._call(
'NotifyPointerButton',
GLib.Variant.new('(ib)', [button, true])
);
}
releasePointer(button) {
button = this._translateButton(button);
this._call(
'NotifyPointerButton',
GLib.Variant.new('(ib)', [button, false])
);
}
clickPointer(button) {
button = this._translateButton(button);
this._call(
'NotifyPointerButton',
GLib.Variant.new('(ib)', [button, true])
);
this._call(
'NotifyPointerButton',
GLib.Variant.new('(ib)', [button, false])
);
}
doubleclickPointer(button) {
this.clickPointer(button);
this.clickPointer(button);
}
scrollPointer(dx, dy) {
if (dy > 0) {
this._call(
'NotifyPointerAxisDiscrete',
GLib.Variant.new('(ui)', [Gdk.ScrollDirection.UP, 1])
);
} else if (dy < 0) {
this._call(
'NotifyPointerAxisDiscrete',
GLib.Variant.new('(ui)', [Gdk.ScrollDirection.UP, -1])
);
}
}
/*
* Keyboard Events
*/
pressKeysym(keysym) {
this._call(
'NotifyKeyboardKeysym',
GLib.Variant.new('(ub)', [keysym, true])
);
}
releaseKeysym(keysym) {
this._call(
'NotifyKeyboardKeysym',
GLib.Variant.new('(ub)', [keysym, false])
);
}
pressreleaseKeysym(keysym) {
this._call(
'NotifyKeyboardKeysym',
GLib.Variant.new('(ub)', [keysym, true])
);
this._call(
'NotifyKeyboardKeysym',
GLib.Variant.new('(ub)', [keysym, false])
);
}
/*
* High-level keyboard input
*/
pressKey(input, modifiers) {
// Press Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this.pressKeysym(Gdk.KEY_Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this.pressKeysym(Gdk.KEY_Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this.pressKeysym(Gdk.KEY_Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this.pressKeysym(Gdk.KEY_Super_L);
if (typeof input === 'string') {
const keysym = Gdk.unicode_to_keyval(input.codePointAt(0));
this.pressreleaseKeysym(keysym);
} else {
this.pressreleaseKeysym(input);
}
// Release Modifiers
if (modifiers & Gdk.ModifierType.MOD1_MASK)
this.releaseKeysym(Gdk.KEY_Alt_L);
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
this.releaseKeysym(Gdk.KEY_Control_L);
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
this.releaseKeysym(Gdk.KEY_Shift_L);
if (modifiers & Gdk.ModifierType.SUPER_MASK)
this.releaseKeysym(Gdk.KEY_Super_L);
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
GObject.signal_handlers_destroy(this);
}
}
});
export default class Controller {
constructor() {
this._nameAppearedId = 0;
this._session = null;
this._sessionCloseId = 0;
this._sessionExpiry = 0;
this._sessionExpiryId = 0;
this._sessionStarting = false;
// Watch for the RemoteDesktop portal
this._nameWatcherId = Gio.bus_watch_name(
Gio.BusType.SESSION,
'org.gnome.Mutter.RemoteDesktop',
Gio.BusNameWatcherFlags.NONE,
this._onNameAppeared.bind(this),
this._onNameVanished.bind(this)
);
}
get connection() {
if (this._connection === undefined)
this._connection = null;
return this._connection;
}
_onNameAppeared(connection, name, name_owner) {
try {
this._connection = connection;
} catch (e) {
logError(e);
}
}
_onNameVanished(connection, name) {
try {
if (this._session !== null)
this._onSessionClosed(this._session);
} catch (e) {
logError(e);
}
}
_onSessionClosed(session) {
// Disconnect from the session
if (this._sessionClosedId > 0) {
session.disconnect(this._sessionClosedId);
this._sessionClosedId = 0;
}
// Destroy the session
session.destroy();
this._session = null;
}
_onSessionExpired() {
// If the session has been used recently, schedule a new expiry
const remainder = Math.floor(this._sessionExpiry - (Date.now() / 1000));
if (remainder > 0) {
this._sessionExpiryId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
remainder,
this._onSessionExpired.bind(this)
);
return GLib.SOURCE_REMOVE;
}
// Otherwise if there's an active session, close it
if (this._session !== null)
this._session.stop();
// Reset the GSource Id
this._sessionExpiryId = 0;
return GLib.SOURCE_REMOVE;
}
async _createRemoteDesktopSession() {
if (this.connection === null)
return Promise.reject(new Error('No DBus connection'));
const reply = await this.connection.call(
'org.gnome.Mutter.RemoteDesktop',
'/org/gnome/Mutter/RemoteDesktop',
'org.gnome.Mutter.RemoteDesktop',
'CreateSession',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null);
return reply.deepUnpack()[0];
}
async _ensureAdapter() {
try {
// Update the timestamp of the last event
this._sessionExpiry = Math.floor((Date.now() / 1000) + SESSION_TIMEOUT);
// Session is active
if (this._session !== null)
return;
// Mutter's RemoteDesktop is not available, fall back to Atspi
if (this.connection === null) {
debug('Falling back to Atspi');
this._session = new AtspiController();
// Mutter is available and there isn't another session starting
} else if (this._sessionStarting === false) {
this._sessionStarting = true;
debug('Creating Mutter RemoteDesktop session');
// This takes three steps: creating the remote desktop session,
// starting the session, and creating a screencast session for
// the remote desktop session.
const objectPath = await this._createRemoteDesktopSession();
this._session = new RemoteSession(objectPath);
await this._session.start();
// Watch for the session ending
this._sessionClosedId = this._session.connect(
'closed',
this._onSessionClosed.bind(this)
);
if (this._sessionExpiryId === 0) {
this._sessionExpiryId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
SESSION_TIMEOUT,
this._onSessionExpired.bind(this)
);
}
this._sessionStarting = false;
}
} catch (e) {
logError(e);
if (this._session !== null) {
this._session.destroy();
this._session = null;
}
this._sessionStarting = false;
}
}
/*
* Pointer Events
*/
movePointer(dx, dy) {
try {
if (dx === 0 && dy === 0)
return;
this._ensureAdapter();
this._session.movePointer(dx, dy);
} catch (e) {
debug(e);
}
}
pressPointer(button) {
try {
this._ensureAdapter();
this._session.pressPointer(button);
} catch (e) {
debug(e);
}
}
releasePointer(button) {
try {
this._ensureAdapter();
this._session.releasePointer(button);
} catch (e) {
debug(e);
}
}
clickPointer(button) {
try {
this._ensureAdapter();
this._session.clickPointer(button);
} catch (e) {
debug(e);
}
}
doubleclickPointer(button) {
try {
this._ensureAdapter();
this._session.doubleclickPointer(button);
} catch (e) {
debug(e);
}
}
scrollPointer(dx, dy) {
if (dx === 0 && dy === 0)
return;
try {
this._ensureAdapter();
this._session.scrollPointer(dx, dy);
} catch (e) {
debug(e);
}
}
/*
* Keyboard Events
*/
pressKeysym(keysym) {
try {
this._ensureAdapter();
this._session.pressKeysym(keysym);
} catch (e) {
debug(e);
}
}
releaseKeysym(keysym) {
try {
this._ensureAdapter();
this._session.releaseKeysym(keysym);
} catch (e) {
debug(e);
}
}
pressreleaseKeysym(keysym) {
try {
this._ensureAdapter();
this._session.pressreleaseKeysym(keysym);
} catch (e) {
debug(e);
}
}
/*
* High-level keyboard input
*/
pressKeys(input, modifiers) {
try {
this._ensureAdapter();
if (typeof input === 'string') {
for (let i = 0; i < input.length; i++)
this._session.pressKey(input[i], modifiers);
} else {
this._session.pressKey(input, modifiers);
}
} catch (e) {
debug(e);
}
}
destroy() {
if (this._session !== null) {
// Disconnect from the session
if (this._sessionClosedId > 0) {
this._session.disconnect(this._sessionClosedId);
this._sessionClosedId = 0;
}
this._session.destroy();
this._session = null;
}
if (this._nameWatcherId > 0) {
Gio.bus_unwatch_name(this._nameWatcherId);
this._nameWatcherId = 0;
}
}
}

View File

@ -0,0 +1,409 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GjsPrivate from 'gi://GjsPrivate';
import GObject from 'gi://GObject';
import * as DBus from '../utils/dbus.js';
const _nodeInfo = Gio.DBusNodeInfo.new_for_xml(`
<node>
<interface name="org.freedesktop.Notifications">
<method name="Notify">
<arg name="appName" type="s" direction="in"/>
<arg name="replacesId" type="u" direction="in"/>
<arg name="iconName" type="s" direction="in"/>
<arg name="summary" type="s" direction="in"/>
<arg name="body" type="s" direction="in"/>
<arg name="actions" type="as" direction="in"/>
<arg name="hints" type="a{sv}" direction="in"/>
<arg name="timeout" type="i" direction="in"/>
</method>
</interface>
<interface name="org.gtk.Notifications">
<method name="AddNotification">
<arg type="s" direction="in"/>
<arg type="s" direction="in"/>
<arg type="a{sv}" direction="in"/>
</method>
<method name="RemoveNotification">
<arg type="s" direction="in"/>
<arg type="s" direction="in"/>
</method>
</interface>
</node>
`);
const FDO_IFACE = _nodeInfo.lookup_interface('org.freedesktop.Notifications');
const FDO_MATCH = "interface='org.freedesktop.Notifications',member='Notify',type='method_call'";
const GTK_IFACE = _nodeInfo.lookup_interface('org.gtk.Notifications');
const GTK_MATCH = "interface='org.gtk.Notifications',member='AddNotification',type='method_call'";
/**
* A class for snooping Freedesktop (libnotify) and Gtk (GNotification)
* notifications and forwarding them to supporting devices.
*/
const Listener = GObject.registerClass({
GTypeName: 'GSConnectNotificationListener',
Signals: {
'notification-added': {
flags: GObject.SignalFlags.RUN_LAST,
param_types: [GLib.Variant.$gtype],
},
},
}, class Listener extends GObject.Object {
_init() {
super._init();
// Respect desktop notification settings
this._settings = new Gio.Settings({
schema_id: 'org.gnome.desktop.notifications',
});
// Watch for new application policies
this._settingsId = this._settings.connect(
'changed::application-children',
this._onSettingsChanged.bind(this)
);
// Cache for appName->desktop-id lookups
this._names = {};
// Asynchronous setup
this._init_async();
}
get applications() {
if (this._applications === undefined)
this._onSettingsChanged();
return this._applications;
}
/**
* Update application notification settings
*/
_onSettingsChanged() {
this._applications = {};
for (const app of this._settings.get_strv('application-children')) {
const appSettings = new Gio.Settings({
schema_id: 'org.gnome.desktop.notifications.application',
path: `/org/gnome/desktop/notifications/application/${app}/`,
});
const appInfo = Gio.DesktopAppInfo.new(
appSettings.get_string('application-id')
);
if (appInfo !== null)
this._applications[appInfo.get_name()] = appSettings;
}
}
async _listNames() {
const reply = await this._session.call(
'org.freedesktop.DBus',
'/org/freedesktop/DBus',
'org.freedesktop.DBus',
'ListNames',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null);
return reply.deepUnpack()[0];
}
async _getNameOwner(name) {
const reply = await this._session.call(
'org.freedesktop.DBus',
'/org/freedesktop/DBus',
'org.freedesktop.DBus',
'GetNameOwner',
new GLib.Variant('(s)', [name]),
null,
Gio.DBusCallFlags.NONE,
-1,
null);
return reply.deepUnpack()[0];
}
/**
* Try and find a well-known name for @sender on the session bus
*
* @param {string} sender - A DBus unique name (eg. :1.2282)
* @param {string} appName - @appName passed to Notify() (Optional)
* @return {string} A well-known name or %null
*/
async _getAppId(sender, appName) {
try {
// Get a list of well-known names, ignoring @sender
const names = await this._listNames();
names.splice(names.indexOf(sender), 1);
// Make a short list for substring matches (fractal/org.gnome.Fractal)
const appLower = appName.toLowerCase();
const shortList = names.filter(name => {
return name.toLowerCase().includes(appLower);
});
// Run the short list first
for (const name of shortList) {
const nameOwner = await this._getNameOwner(name);
if (nameOwner === sender)
return name;
names.splice(names.indexOf(name), 1);
}
// Run the full list
for (const name of names) {
const nameOwner = await this._getNameOwner(name);
if (nameOwner === sender)
return name;
}
return null;
} catch (e) {
debug(e);
return null;
}
}
/**
* Try and find the application name for @sender
*
* @param {string} sender - A DBus unique name
* @param {string} [appName] - `appName` supplied by Notify()
* @return {string} A well-known name or %null
*/
async _getAppName(sender, appName = null) {
// Check the cache first
if (appName && this._names.hasOwnProperty(appName))
return this._names[appName];
try {
const appId = await this._getAppId(sender, appName);
const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`);
this._names[appName] = appInfo.get_name();
appName = appInfo.get_name();
} catch (e) {
// Silence errors
}
return appName;
}
/**
* Callback for AddNotification()/Notify()
*
* @param {DBus.Interface} iface - The DBus interface
* @param {string} name - The DBus method name
* @param {GLib.Variant} parameters - The method parameters
* @param {Gio.DBusMethodInvocation} invocation - The method invocation info
*/
async _onHandleMethodCall(iface, name, parameters, invocation) {
try {
// Check if notifications are disabled in desktop settings
if (!this._settings.get_boolean('show-banners'))
return;
parameters = parameters.full_unpack();
// GNotification
if (name === 'AddNotification') {
this.AddNotification(...parameters);
// libnotify
} else if (name === 'Notify') {
const message = invocation.get_message();
const destination = message.get_destination();
// Deduplicate notifications; only accept messages
// directed to the notification bus, or its owner.
if (destination !== 'org.freedesktop.Notifications') {
if (this._fdoNameOwner === undefined) {
this._fdoNameOwner = await this._getNameOwner(
'org.freedesktop.Notifications');
}
if (this._fdoNameOwner !== destination)
return;
}
// Try to brute-force an application name using DBus
if (!this.applications.hasOwnProperty(parameters[0])) {
const sender = message.get_sender();
parameters[0] = await this._getAppName(sender, parameters[0]);
}
this.Notify(...parameters);
}
} catch (e) {
debug(e);
}
}
/**
* Export interfaces for proxying notifications and become a monitor
*
* @return {Promise} A promise for the operation
*/
_monitorConnection() {
// libnotify Interface
this._fdoNotifications = new GjsPrivate.DBusImplementation({
g_interface_info: FDO_IFACE,
});
this._fdoMethodCallId = this._fdoNotifications.connect(
'handle-method-call', this._onHandleMethodCall.bind(this));
this._fdoNotifications.export(this._monitor,
'/org/freedesktop/Notifications');
this._fdoNameOwnerChangedId = this._session.signal_subscribe(
'org.freedesktop.DBus',
'org.freedesktop.DBus',
'NameOwnerChanged',
'/org/freedesktop/DBus',
'org.freedesktop.Notifications',
Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE,
this._onFdoNameOwnerChanged.bind(this)
);
// GNotification Interface
this._gtkNotifications = new GjsPrivate.DBusImplementation({
g_interface_info: GTK_IFACE,
});
this._gtkMethodCallId = this._gtkNotifications.connect(
'handle-method-call', this._onHandleMethodCall.bind(this));
this._gtkNotifications.export(this._monitor, '/org/gtk/Notifications');
// Become a monitor for Fdo & Gtk notifications
return this._monitor.call(
'org.freedesktop.DBus',
'/org/freedesktop/DBus',
'org.freedesktop.DBus.Monitoring',
'BecomeMonitor',
new GLib.Variant('(asu)', [[FDO_MATCH, GTK_MATCH], 0]),
null,
Gio.DBusCallFlags.NONE,
-1,
null);
}
async _init_async() {
try {
this._session = Gio.DBus.session;
this._monitor = await DBus.newConnection();
await this._monitorConnection();
} catch (e) {
const service = Gio.Application.get_default();
if (service !== null)
service.notify_error(e);
else
logError(e);
}
}
_onFdoNameOwnerChanged(connection, sender, object, iface, signal, parameters) {
this._fdoNameOwner = parameters.deepUnpack()[2];
}
_sendNotification(notif) {
// Check if this application is disabled in desktop settings
const appSettings = this.applications[notif.appName];
if (appSettings && !appSettings.get_boolean('enable'))
return;
// Send the notification to each supporting device
// TODO: avoid the overhead of the GAction framework with a signal?
const variant = GLib.Variant.full_pack(notif);
this.emit('notification-added', variant);
}
Notify(appName, replacesId, iconName, summary, body, actions, hints, timeout) {
// Ignore notifications without an appName
if (!appName)
return;
this._sendNotification({
appName: appName,
id: `fdo|null|${replacesId}`,
title: summary,
text: body,
ticker: `${summary}: ${body}`,
isClearable: (replacesId !== 0),
icon: iconName,
});
}
AddNotification(application, id, notification) {
// Ignore our own notifications or we'll cause a notification loop
if (application === 'org.gnome.Shell.Extensions.GSConnect')
return;
const appInfo = Gio.DesktopAppInfo.new(`${application}.desktop`);
// Try to get an icon for the notification
if (!notification.hasOwnProperty('icon'))
notification.icon = appInfo.get_icon() || undefined;
this._sendNotification({
appName: appInfo.get_name(),
id: `gtk|${application}|${id}`,
title: notification.title,
text: notification.body,
ticker: `${notification.title}: ${notification.body}`,
isClearable: true,
icon: notification.icon,
});
}
destroy() {
try {
if (this._fdoNotifications) {
this._fdoNotifications.disconnect(this._fdoMethodCallId);
this._fdoNotifications.unexport();
this._session.signal_unsubscribe(this._fdoNameOwnerChangedId);
}
if (this._gtkNotifications) {
this._gtkNotifications.disconnect(this._gtkMethodCallId);
this._gtkNotifications.unexport();
}
if (this._settings) {
this._settings.disconnect(this._settingsId);
this._settings.run_dispose();
}
// TODO: Gio.IOErrorEnum: The connection is closed
// this._monitor.close_sync(null);
GObject.signal_handlers_destroy(this);
} catch (e) {
debug(e);
}
}
});
/**
* The service class for this component
*/
export default Listener;

View File

@ -0,0 +1,271 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GIRepository from 'gi://GIRepository';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
const Tweener = imports.tweener.tweener;
let Gvc = null;
try {
// Add gnome-shell's typelib dir to the search path
const typelibDir = GLib.build_filenamev([Config.GNOME_SHELL_LIBDIR, 'gnome-shell']);
GIRepository.Repository.prepend_search_path(typelibDir);
GIRepository.Repository.prepend_library_path(typelibDir);
Gvc = (await import('gi://Gvc')).default;
} catch (e) {}
/**
* Extend Gvc.MixerStream with a property for returning a user-visible name
*/
if (Gvc) {
Object.defineProperty(Gvc.MixerStream.prototype, 'display_name', {
get: function () {
try {
if (!this.get_ports().length)
return this.description;
return `${this.get_port().human_port} (${this.description})`;
} catch (e) {
return this.description;
}
},
});
}
/**
* A convenience wrapper for Gvc.MixerStream
*/
class Stream {
constructor(mixer, stream) {
this._mixer = mixer;
this._stream = stream;
this._max = mixer.get_vol_max_norm();
}
get muted() {
return this._stream.is_muted;
}
set muted(bool) {
this._stream.change_is_muted(bool);
}
// Volume is a double in the range 0-1
get volume() {
return Math.floor(100 * this._stream.volume / this._max) / 100;
}
set volume(num) {
this._stream.volume = Math.floor(num * this._max);
this._stream.push_volume();
}
/**
* Gradually raise or lower the stream volume to @value
*
* @param {number} value - A number in the range 0-1
* @param {number} [duration] - Duration to fade in seconds
*/
fade(value, duration = 1) {
Tweener.removeTweens(this);
if (this._stream.volume > value) {
this._mixer.fading = true;
Tweener.addTween(this, {
volume: value,
time: duration,
transition: 'easeOutCubic',
onComplete: () => {
this._mixer.fading = false;
},
});
} else if (this._stream.volume < value) {
this._mixer.fading = true;
Tweener.addTween(this, {
volume: value,
time: duration,
transition: 'easeInCubic',
onComplete: () => {
this._mixer.fading = false;
},
});
}
}
}
/**
* A subclass of Gvc.MixerControl with convenience functions for controlling the
* default input/output volumes.
*
* The Mixer class uses GNOME Shell's Gvc library to control the system volume
* and offers a few convenience functions.
*/
const Mixer = !Gvc ? null : GObject.registerClass({
GTypeName: 'GSConnectAudioMixer',
}, class Mixer extends Gvc.MixerControl {
_init(params) {
super._init({name: 'GSConnect'});
this._previousVolume = undefined;
this._volumeMuted = false;
this._microphoneMuted = false;
this.open();
}
get fading() {
if (this._fading === undefined)
this._fading = false;
return this._fading;
}
set fading(bool) {
if (this.fading === bool)
return;
this._fading = bool;
if (this.fading)
this.emit('stream-changed', this._output._stream.id);
}
get input() {
if (this._input === undefined)
this.vfunc_default_source_changed();
return this._input;
}
get output() {
if (this._output === undefined)
this.vfunc_default_sink_changed();
return this._output;
}
vfunc_default_sink_changed(id) {
try {
const sink = this.get_default_sink();
this._output = (sink) ? new Stream(this, sink) : null;
} catch (e) {
logError(e);
}
}
vfunc_default_source_changed(id) {
try {
const source = this.get_default_source();
this._input = (source) ? new Stream(this, source) : null;
} catch (e) {
logError(e);
}
}
vfunc_state_changed(new_state) {
try {
if (new_state === Gvc.MixerControlState.READY) {
this.vfunc_default_sink_changed(null);
this.vfunc_default_source_changed(null);
}
} catch (e) {
logError(e);
}
}
/**
* Store the current output volume then lower it to %15
*
* @param {number} duration - Duration in seconds to fade
*/
lowerVolume(duration = 1) {
try {
if (this.output && this.output.volume > 0.15) {
this._previousVolume = Number(this.output.volume);
this.output.fade(0.15, duration);
}
} catch (e) {
logError(e);
}
}
/**
* Mute the output volume (speakers)
*/
muteVolume() {
try {
if (!this.output || this.output.muted)
return;
this.output.muted = true;
this._volumeMuted = true;
} catch (e) {
logError(e);
}
}
/**
* Mute the input volume (microphone)
*/
muteMicrophone() {
try {
if (!this.input || this.input.muted)
return;
this.input.muted = true;
this._microphoneMuted = true;
} catch (e) {
logError(e);
}
}
/**
* Restore all mixer levels to their previous state
*/
restore() {
try {
// If we muted the microphone, unmute it before restoring the volume
if (this._microphoneMuted) {
this.input.muted = false;
this._microphoneMuted = false;
}
// If we muted the volume, unmute it before restoring the volume
if (this._volumeMuted) {
this.output.muted = false;
this._volumeMuted = false;
}
// If a previous volume is defined, raise it back up to that level
if (this._previousVolume !== undefined) {
this.output.fade(this._previousVolume);
this._previousVolume = undefined;
}
} catch (e) {
logError(e);
}
}
destroy() {
this.close();
}
});
/**
* The service class for this component
*/
export default Mixer;

View File

@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
const Session = class {
constructor() {
this._connection = Gio.DBus.system;
this._session = null;
this._initAsync();
}
async _initAsync() {
try {
const reply = await this._connection.call(
'org.freedesktop.login1',
'/org/freedesktop/login1',
'org.freedesktop.login1.Manager',
'ListSessions',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null);
const sessions = reply.deepUnpack()[0];
const userName = GLib.get_user_name();
let sessionPath = '/org/freedesktop/login1/session/auto';
// eslint-disable-next-line no-unused-vars
for (const [num, uid, name, seat, objectPath] of sessions) {
if (name === userName) {
sessionPath = objectPath;
break;
}
}
this._session = new Gio.DBusProxy({
g_connection: this._connection,
g_name: 'org.freedesktop.login1',
g_object_path: sessionPath,
g_interface_name: 'org.freedesktop.login1.Session',
});
await this._session.init_async(GLib.PRIORITY_DEFAULT, null);
} catch (e) {
this._session = null;
logError(e);
}
}
get idle() {
if (this._session === null)
return false;
return this._session.get_cached_property('IdleHint').unpack();
}
get locked() {
if (this._session === null)
return false;
return this._session.get_cached_property('LockedHint').unpack();
}
get active() {
// Active if not idle and not locked
return !(this.idle || this.locked);
}
destroy() {
this._session = null;
}
};
/**
* The service class for this component
*/
export default Session;

View File

@ -0,0 +1,172 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
let GSound = null;
try {
GSound = (await import('gi://GSound')).default;
} catch (e) {}
const Player = class Player {
constructor() {
this._playing = new Set();
}
get backend() {
if (this._backend === undefined) {
// Prefer GSound
if (GSound !== null) {
this._gsound = new GSound.Context();
this._gsound.init(null);
this._backend = 'gsound';
// Try falling back to libcanberra, otherwise just re-run the test
// in case one or the other is installed later
} else if (GLib.find_program_in_path('canberra-gtk-play') !== null) {
this._canberra = new Gio.SubprocessLauncher({
flags: Gio.SubprocessFlags.NONE,
});
this._backend = 'libcanberra';
} else {
return null;
}
}
return this._backend;
}
_canberraPlaySound(name, cancellable) {
const proc = this._canberra.spawnv(['canberra-gtk-play', '-i', name]);
return proc.wait_check_async(cancellable);
}
async _canberraLoopSound(name, cancellable) {
while (!cancellable.is_cancelled())
await this._canberraPlaySound(name, cancellable);
}
_gsoundPlaySound(name, cancellable) {
return new Promise((resolve, reject) => {
this._gsound.play_full(
{'event.id': name},
cancellable,
(source, res) => {
try {
resolve(source.play_full_finish(res));
} catch (e) {
reject(e);
}
}
);
});
}
async _gsoundLoopSound(name, cancellable) {
while (!cancellable.is_cancelled())
await this._gsoundPlaySound(name, cancellable);
}
_gdkPlaySound(name, cancellable) {
if (this._display === undefined)
this._display = Gdk.Display.get_default();
let count = 0;
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 200, () => {
try {
if (count++ < 4 && !cancellable.is_cancelled()) {
this._display.beep();
return GLib.SOURCE_CONTINUE;
}
return GLib.SOURCE_REMOVE;
} catch (e) {
logError(e);
return GLib.SOURCE_REMOVE;
}
});
return !cancellable.is_cancelled();
}
_gdkLoopSound(name, cancellable) {
this._gdkPlaySound(name, cancellable);
GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
1500,
this._gdkPlaySound.bind(this, name, cancellable)
);
}
async playSound(name, cancellable) {
try {
if (!(cancellable instanceof Gio.Cancellable))
cancellable = new Gio.Cancellable();
this._playing.add(cancellable);
switch (this.backend) {
case 'gsound':
await this._gsoundPlaySound(name, cancellable);
break;
case 'canberra':
await this._canberraPlaySound(name, cancellable);
break;
default:
await this._gdkPlaySound(name, cancellable);
}
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
} finally {
this._playing.delete(cancellable);
}
}
async loopSound(name, cancellable) {
try {
if (!(cancellable instanceof Gio.Cancellable))
cancellable = new Gio.Cancellable();
this._playing.add(cancellable);
switch (this.backend) {
case 'gsound':
await this._gsoundLoopSound(name, cancellable);
break;
case 'canberra':
await this._canberraLoopSound(name, cancellable);
break;
default:
await this._gdkLoopSound(name, cancellable);
}
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
} finally {
this._playing.delete(cancellable);
}
}
destroy() {
for (const cancellable of this._playing)
cancellable.cancel();
}
};
/**
* The service class for this component
*/
export default Player;

View File

@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
/**
* The warning level of a battery.
*
* @readonly
* @enum {number}
*/
const DeviceLevel = {
UNKNOWN: 0,
NONE: 1,
DISCHARGING: 2,
LOW: 3,
CRITICAL: 4,
ACTION: 5,
NORMAL: 6,
HIGH: 7,
FULL: 8,
LAST: 9,
};
/**
* The device state.
*
* @readonly
* @enum {number}
*/
const DeviceState = {
UNKNOWN: 0,
CHARGING: 1,
DISCHARGING: 2,
EMPTY: 3,
FULLY_CHARGED: 4,
PENDING_CHARGE: 5,
PENDING_DISCHARGE: 6,
LAST: 7,
};
/**
* A class representing the system battery.
*/
const Battery = GObject.registerClass({
GTypeName: 'GSConnectSystemBattery',
Signals: {
'changed': {
flags: GObject.SignalFlags.RUN_FIRST,
},
},
Properties: {
'charging': GObject.ParamSpec.boolean(
'charging',
'Charging',
'The current charging state.',
GObject.ParamFlags.READABLE,
false
),
'level': GObject.ParamSpec.int(
'level',
'Level',
'The current power level.',
GObject.ParamFlags.READABLE,
-1, 100,
-1
),
'threshold': GObject.ParamSpec.uint(
'threshold',
'Threshold',
'The current threshold state.',
GObject.ParamFlags.READABLE,
0, 1,
0
),
},
}, class Battery extends GObject.Object {
_init() {
super._init();
this._cancellable = new Gio.Cancellable();
this._proxy = null;
this._propertiesChangedId = 0;
this._loadUPower();
}
async _loadUPower() {
try {
this._proxy = new Gio.DBusProxy({
g_bus_type: Gio.BusType.SYSTEM,
g_name: 'org.freedesktop.UPower',
g_object_path: '/org/freedesktop/UPower/devices/DisplayDevice',
g_interface_name: 'org.freedesktop.UPower.Device',
g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
});
await this._proxy.init_async(GLib.PRIORITY_DEFAULT,
this._cancellable);
this._propertiesChangedId = this._proxy.connect(
'g-properties-changed', this._onPropertiesChanged.bind(this));
this._initProperties(this._proxy);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
const service = Gio.Application.get_default();
if (service !== null)
service.notify_error(e);
else
logError(e);
}
this._proxy = null;
}
}
_initProperties(proxy) {
if (proxy.g_name_owner === null)
return;
const percentage = proxy.get_cached_property('Percentage').unpack();
const state = proxy.get_cached_property('State').unpack();
const level = proxy.get_cached_property('WarningLevel').unpack();
this._level = Math.floor(percentage);
this._charging = (state !== DeviceState.DISCHARGING);
this._threshold = (!this.charging && level >= DeviceLevel.LOW);
this.emit('changed');
}
_onPropertiesChanged(proxy, changed, invalidated) {
let emitChanged = false;
const properties = changed.deepUnpack();
if (properties.hasOwnProperty('Percentage')) {
emitChanged = true;
const value = proxy.get_cached_property('Percentage').unpack();
this._level = Math.floor(value);
this.notify('level');
}
if (properties.hasOwnProperty('State')) {
emitChanged = true;
const value = proxy.get_cached_property('State').unpack();
this._charging = (value !== DeviceState.DISCHARGING);
this.notify('charging');
}
if (properties.hasOwnProperty('WarningLevel')) {
emitChanged = true;
const value = proxy.get_cached_property('WarningLevel').unpack();
this._threshold = (!this.charging && value >= DeviceLevel.LOW);
this.notify('threshold');
}
if (emitChanged)
this.emit('changed');
}
get charging() {
if (this._charging === undefined)
this._charging = false;
return this._charging;
}
get is_present() {
return (this._proxy && this._proxy.g_name_owner);
}
get level() {
if (this._level === undefined)
this._level = -1;
return this._level;
}
get threshold() {
if (this._threshold === undefined)
this._threshold = 0;
return this._threshold;
}
destroy() {
if (this._cancellable.is_cancelled())
return;
this._cancellable.cancel();
if (this._proxy && this._propertiesChangedId > 0) {
this._proxy.disconnect(this._propertiesChangedId);
this._propertiesChangedId = 0;
}
}
});
/**
* The service class for this component
*/
export default Battery;

View File

@ -0,0 +1,160 @@
// SPDX-FileCopyrightText: JingMatrix https://github.com/JingMatrix
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import Gdk from 'gi://Gdk';
const keyCodes = new Map([
['1', 2],
['2', 3],
['3', 4],
['4', 5],
['5', 6],
['6', 7],
['7', 8],
['8', 9],
['9', 10],
['0', 11],
['-', 12],
['=', 13],
['Q', 16],
['W', 17],
['E', 18],
['R', 19],
['T', 20],
['Y', 21],
['U', 22],
['I', 23],
['O', 24],
['P', 25],
['[', 26],
[']', 27],
['A', 30],
['S', 31],
['D', 32],
['F', 33],
['G', 34],
['H', 35],
['J', 36],
['K', 37],
['L', 38],
[';', 39],
["'", 40],
['Z', 44],
['X', 45],
['C', 46],
['V', 47],
['B', 48],
['N', 49],
['M', 50],
[',', 51],
['.', 52],
['/', 53],
['\\', 43],
]);
export default class Controller {
constructor() {
// laucher for wl-clipboard
this._launcher = new Gio.SubprocessLauncher({
flags:
Gio.SubprocessFlags.STDOUT_PIPE |
Gio.SubprocessFlags.STDERR_MERGE,
});
this._args = [];
this.buttonMap = new Map([
[Gdk.BUTTON_PRIMARY, '0'],
[Gdk.BUTTON_MIDDLE, '2'],
[Gdk.BUTTON_SECONDARY, '1'],
]);
}
get args() {
return this._args;
}
set args(opts) {
this._args = ['ydotool'].concat(opts);
try {
this._launcher.spawnv(this._args);
} catch (e) {
debug(e, this._args);
}
}
/*
* Pointer Events
*/
movePointer(dx, dy) {
if (dx === 0 && dy === 0)
return;
this.args = ['mousemove', '--', dx.toString(), dy.toString()];
}
pressPointer(button) {
this.args = ['click', '0x4' + this.buttonMap.get(button)];
}
releasePointer(button) {
this.args = ['click', '0x8' + this.buttonMap.get(button)];
}
clickPointer(button) {
this.args = ['click', '0xC' + this.buttonMap.get(button)];
}
doubleclickPointer(button) {
this.args = [
'click',
'0xC' + this.buttonMap.get(button),
'click',
'0xC' + this.buttonMap.get(button),
];
}
scrollPointer(dx, dy) {
if (dx === 0 && dy === 0)
return;
this.args = ['mousemove', '-w', '--', dx.toString(), dy.toString()];
}
/*
* Keyboard Events
*/
pressKeys(input, modifiers_codes) {
if (typeof input === 'string' && modifiers_codes.length === 0) {
try {
this._launcher.spawnv(['wtype', input]);
} catch (e) {
debug(e);
this.arg = ['type', '--', input];
}
} else {
if (typeof input === 'number') {
modifiers_codes.push(input);
} else if (typeof input === 'string') {
input = input.toUpperCase();
for (let i = 0; i < input.length; i++) {
if (keyCodes.get(input[i])) {
modifiers_codes.push(keyCodes.get(input[i]));
} else {
debug('Keycode for ' + input[i] + ' not found');
return;
}
}
}
this._args = ['key'];
modifiers_codes.forEach((code) => this._args.push(code + ':1'));
modifiers_codes
.reverse()
.forEach((code) => this._args.push(code + ':0'));
this.args = this._args;
}
}
destroy() {
this._args = [];
}
}