Add some configs from generic Ubuntu

This commit is contained in:
2020-05-11 05:16:27 -04:00
parent f32c1048d1
commit 754f64f135
16037 changed files with 205635 additions and 137 deletions

View File

@@ -0,0 +1,324 @@
'use strict';
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Gtk = imports.gi.Gtk;
const ByteArray = imports.byteArray;
/**
* Check if we're in a Wayland session (mostly for input synthesis)
* https://wiki.gnome.org/Accessibility/Wayland#Bugs.2FIssues_We_Must_Address
*/
window._WAYLAND = GLib.getenv('XDG_SESSION_TYPE') === 'wayland';
window.HAVE_REMOTEINPUT = GLib.getenv('GDMSESSION') !== 'ubuntu-wayland';
/**
* A custom debug function that logs at LEVEL_MESSAGE to avoid the need for env
* variables to be set.
*
* @param {Error|string} message - A string or Error to log
* @param {string} [prefix] - An optional prefix for the warning
*/
const _debugFunc = function(message, prefix = null) {
let caller;
if (message.stack) {
caller = message.stack.split('\n')[0];
message = `${message.message}\n${message.stack}`;
} else {
message = JSON.stringify(message, null, 2);
caller = (new Error()).stack.split('\n')[1];
}
// Prepend prefix
message = (prefix) ? `${prefix}: ${message}` : message;
// Cleanup the stack
let [, func, file, line] = caller.match(/([^@]*)@([^:]*):([^:]*)/);
let script = file.replace(gsconnect.extdatadir, '');
GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, {
'MESSAGE': `[${script}:${func}:${line}]: ${message}`,
'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect',
'CODE_FILE': file,
'CODE_FUNC': func,
'CODE_LINE': line
});
};
// Swap the function out for a no-op anonymous function for speed
window.debug = gsconnect.settings.get_boolean('debug') ? _debugFunc : () => {};
gsconnect.settings.connect('changed::debug', (settings) => {
window.debug = settings.get_boolean('debug') ? _debugFunc : () => {};
});
/**
* Convenience function for loading JSON from a file
*
* @param {Gio.File|string} file - A Gio.File or path to a JSON file
* @param {boolean} sync - Default is %false, if %true load synchronously
* @return {object} - The parsed object
*/
JSON.load = function (file, sync = false) {
if (typeof file === 'string') {
file = Gio.File.new_for_path(file);
}
if (sync) {
let contents = file.load_contents(null)[1];
return JSON.parse(ByteArray.toString(contents));
} else {
return new Promise((resolve, reject) => {
file.load_contents_async(null, (file, res) => {
try {
let contents = file.load_contents_finish(res)[1];
resolve(JSON.parse(ByteArray.toString(contents)));
} catch (e) {
reject(e);
}
});
});
}
};
/**
* Convenience function for dumping JSON to a file
*
* @param {Gio.File|string} file - A Gio.File or file path
* @param {object} obj - The object to write to disk
* @param {boolean} sync - Default is %false, if %true load synchronously
*/
JSON.dump = function (obj, file, sync = false) {
if (typeof file === 'string') {
file = Gio.File.new_for_path(file);
}
if (sync) {
file.replace_contents(
JSON.stringify(obj, null, 2),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null
);
} else {
return new Promise((resolve, reject) => {
file.replace_contents_bytes_async(
new GLib.Bytes(JSON.stringify(obj, null, 2)),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null,
(file, res) => {
try {
file.replace_contents_finish(res);
resolve();
} catch (e) {
reject(e);
}
}
);
});
}
};
/**
* Idle Promise
*
* @param {number} priority - The priority of the idle source
*/
Promise.idle = function(priority) {
return new Promise(resolve => GLib.idle_add(priority, resolve));
};
/**
* Timeout Promise
*
* @param {number} priority - The priority of the timeout source
* @param {number} interval - Delay in milliseconds before resolving
*/
Promise.timeout = function(priority = GLib.PRIORITY_DEFAULT, interval = 100) {
return new Promise(resolve => GLib.timeout_add(priority, interval, resolve));
};
/**
* A simple (for now) pre-comparison sanitizer for phone numbers
* See: https://github.com/KDE/kdeconnect-kde/blob/master/smsapp/conversationlistmodel.cpp#L200-L210
*
* @return {string} - Return the string stripped of leading 0, and ' ()-+'
*/
String.prototype.toPhoneNumber = function() {
let strippedNumber = this.replace(/^0*|[ ()+-]/g, '');
if (strippedNumber.length)
return strippedNumber;
return this;
};
/**
* A simple equality check for phone numbers based on `toPhoneNumber()`
*
* @param {string} number - A phone number string to compare
* @return {boolean} - If `this` and @number are equivalent phone numbers
*/
String.prototype.equalsPhoneNumber = function(number) {
let a = this.toPhoneNumber();
let b = number.toPhoneNumber();
return (a.endsWith(b) || b.endsWith(a));
};
/**
* An implementation of `rm -rf` in Gio
*/
Gio.File.rm_rf = function(file) {
try {
if (typeof file === 'string') {
file = Gio.File.new_for_path(file);
}
try {
let iter = file.enumerate_children(
'standard::name',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null
);
let info;
while ((info = iter.next_file(null))) {
Gio.File.rm_rf(iter.get_child(info));
}
iter.close(null);
} catch (e) {
// Silence errors
}
file.delete(null);
} catch (e) {
// Silence errors
}
};
/**
* Extend GLib.Variant with a static method to recursively pack a variant
*
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
*/
function _full_pack(obj) {
let packed;
let type = typeof obj;
switch (true) {
case (obj instanceof GLib.Variant):
return obj;
case (type === 'string'):
return GLib.Variant.new('s', obj);
case (type === 'number'):
return GLib.Variant.new('d', obj);
case (type === 'boolean'):
return GLib.Variant.new('b', obj);
case (obj instanceof Uint8Array):
return GLib.Variant.new('ay', obj);
case (obj === null):
return GLib.Variant.new('mv', null);
case (typeof obj.map === 'function'):
return GLib.Variant.new(
'av',
obj.filter(e => e !== undefined).map(e => _full_pack(e))
);
case (obj instanceof Gio.Icon):
return obj.serialize();
case (type === 'object'):
packed = {};
for (let [key, val] of Object.entries(obj)) {
if (val !== undefined) {
packed[key] = _full_pack(val);
}
}
return GLib.Variant.new('a{sv}', packed);
default:
throw Error(`Unsupported type '${type}': ${obj}`);
}
}
GLib.Variant.full_pack = _full_pack;
/**
* Extend GLib.Variant with a method to recursively deepUnpack() a variant
*
* TODO: this is duplicated in components/dbus.js and it probably shouldn't be,
* but dbus.js can stand on it's own if it is...
*
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
*/
function _full_unpack(obj) {
obj = (obj === undefined) ? this : obj;
let unpacked;
switch (true) {
case (obj === null):
return obj;
case (obj instanceof GLib.Variant):
return _full_unpack(obj.deepUnpack());
case (obj instanceof Uint8Array):
return obj;
case (typeof obj.map === 'function'):
return obj.map(e => _full_unpack(e));
case (typeof obj === 'object'):
unpacked = {};
for (let [key, value] of Object.entries(obj)) {
// Try to detect and deserialize GIcons
try {
if (key === 'icon' && value.get_type_string() === '(sv)') {
unpacked[key] = Gio.Icon.deserialize(value);
} else {
unpacked[key] = _full_unpack(value);
}
} catch (e) {
unpacked[key] = _full_unpack(value);
}
}
return unpacked;
default:
return obj;
}
}
GLib.Variant.prototype.full_unpack = _full_unpack;

View File

@@ -0,0 +1,299 @@
'use strict';
imports.gi.versions.Atspi = '2.0';
const Atspi = imports.gi.Atspi;
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
/**
* Printable ASCII range
*/
const _ASCII = /[\x20-\x7E]/;
/**
* Modifier Keycode Defaults
*/
const XKeycode = {
Alt_L: 0x40,
Control_L: 0x25,
Shift_L: 0x32,
Super_L: 0x85
};
var Controller = class {
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 {
let 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 {
let [, x, y] = this._pointer.get_position();
let monitor = this._display.get_monitor_at_point(x, y);
let scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}c`);
} catch (e) {
logError(e);
}
}
doubleclickPointer(button) {
try {
let [, x, y] = this._pointer.get_position();
let monitor = this._display.get_monitor_at_point(x, y);
let scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}d`);
} catch (e) {
logError(e);
}
}
movePointer(dx, dy) {
try {
let [, x, y] = this._pointer.get_position();
let monitor = this._display.get_monitor_at_point(x, y);
let scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * dx, scale * dy, 'rel');
} catch (e) {
logError(e);
}
}
pressPointer(button) {
try {
let [, x, y] = this._pointer.get_position();
let monitor = this._display.get_monitor_at_point(x, y);
let scale = monitor.get_scale_factor();
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}p`);
} catch (e) {
logError(e);
}
}
releasePointer(button) {
try {
let [, x, y] = this._pointer.get_position();
let monitor = this._display.get_monitor_at_point(x, y);
let 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 {object} input - 'body' of a 'kdeconnect.mousepad.request' packet
*/
_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
let 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,241 @@
'use strict';
const Gdk = imports.gi.Gdk;
const GLib = imports.gi.GLib;
const Gtk = imports.gi.Gtk;
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const ClipboardProxy = GObject.registerClass({
GTypeName: 'GSConnectClipboardProxy',
Implements: [Gio.DBusInterface],
Properties: {
'text': GObject.ParamSpec.string(
'text',
'Text Content',
'The current text content of the clipboard',
GObject.ParamFlags.READWRITE,
''
)
}
}, class ClipboardProxy extends Gio.DBusProxy {
_init() {
super._init({
g_bus_type: Gio.BusType.SESSION,
g_name: 'org.gnome.Shell.Extensions.GSConnect.Clipboard',
g_object_path: '/org/gnome/Shell/Extensions/GSConnect/Clipboard',
g_interface_name: 'org.gnome.Shell.Extensions.GSConnect.Clipboard'
});
}
vfunc_g_properties_changed(changed, invalidated) {
let properties = changed.deepUnpack();
if (properties.hasOwnProperty('Text')) {
let content = this.get_cached_property('Text').unpack();
if (this.text !== content) {
this._text = content;
this.notify('text');
}
}
}
get text() {
if (this._text === undefined) {
this._text = this.get_cached_property('Text').unpack();
}
return this._text;
}
set text(content) {
if (this.text !== content) {
this._text = content;
this.notify('text');
this._setProperty('Text', 's', content);
}
}
_setProperty(name, signature, value) {
let variant = new GLib.Variant(signature, value);
this.set_cached_property(name, variant);
this.call(
'org.freedesktop.DBus.Properties.Set',
new GLib.Variant('(ssv)', [this.g_interface_name, name, variant]),
Gio.DBusCallFlags.NONE,
-1,
null,
null
);
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
this.run_dispose();
}
}
});
var 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();
try {
this._clipboard = null;
// On Wayland we use a small DBus server exported from the Shell
if (_WAYLAND) {
this._nameWatcherId = Gio.bus_watch_name(
Gio.BusType.SESSION,
'org.gnome.Shell.Extensions.GSConnect.Clipboard',
Gio.BusNameWatcherFlags.NONE,
this._onNameAppeared.bind(this),
this._onNameVanished.bind(this)
);
// If we're in X11/Xorg we're just a wrapper around GtkClipboard
} else {
let display = Gdk.Display.get_default();
this._clipboard = Gtk.Clipboard.get_default(display);
this._ownerChangeId = this._clipboard.connect(
'owner-change',
this._onOwnerChange.bind(this)
);
}
} catch (e) {
this.destroy();
throw e;
}
}
get text() {
if (this._text === undefined) {
this._text = '';
}
return this._text;
}
set text(content) {
if (this.text !== content) {
this._text = content;
this.notify('text');
if (!_WAYLAND && content !== null) {
this._clipboard.set_text(content, -1);
}
}
}
async _onNameAppeared(connection, name, name_owner) {
try {
this._clipboard = new ClipboardProxy();
await new Promise((resolve, reject) => {
this._clipboard.init_async(
GLib.PRIORITY_DEFAULT,
null,
(proxy, res) => {
try {
proxy.init_finish(res);
resolve();
} catch (e) {
this._clipboard = null;
reject(e);
}
}
);
});
this._clipboard.bind_property(
'text',
this,
'text',
(GObject.BindingFlags.BIDIRECTIONAL |
GObject.BindingFlags.SYNC_CREATE)
);
} catch (e) {
logError(e);
}
}
_onNameVanished(connection, name) {
try {
if (this._clipboard !== null) {
this._clipboard.destroy();
this._clipboard = null;
}
} catch (e) {
logError(e);
}
}
_onTextReceived(clipboard, text) {
if (typeof text === 'string' && this.text !== text) {
this._text = text;
this.notify('text');
}
}
_onTargetsReceived(clipboard, atoms) {
// Empty clipboard
if (atoms.length === 0) {
this._onTextReceived(clipboard, '');
return;
}
// As a special case we need to ignore copied files (eg. in Nautilus)
if (atoms.includes('text/uri-list')) {
return;
}
// Let GtkClipboard filter for supported types
clipboard.request_text(this._onTextReceived.bind(this));
}
_onOwnerChange(clipboard, event) {
clipboard.request_targets(this._onTargetsReceived.bind(this));
}
destroy() {
if (this._nameWatcherId) {
Gio.bus_unwatch_name(this._nameWatcherId);
if (this._clipboard !== null) {
this._clipboard.destroy();
}
}
if (this._ownerChangeId) {
this._clipboard.disconnect(this._ownerChangeId);
}
}
});
/**
* The service class for this component
*/
var Component = Clipboard;

View File

@@ -0,0 +1,700 @@
'use strict';
const GdkPixbuf = imports.gi.GdkPixbuf;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
var HAVE_EDS = true;
try {
var EBook = imports.gi.EBook;
var EBookContacts = imports.gi.EBookContacts;
var EDataServer = imports.gi.EDataServer;
} catch (e) {
HAVE_EDS = false;
}
/**
* A store for contacts
*/
var Store = GObject.registerClass({
GTypeName: 'GSConnectContactsStore',
Properties: {
'context': GObject.ParamSpec.string(
'context',
'Context',
'Used as the cache directory, relative to gsconnect.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.__cache_data = {};
// If Evolution Data Server is available, load it now
if (context === null && HAVE_EDS) {
this._ebooks = new Map();
this._initEvolutionDataServer();
}
}
/**
* Parse an EContact and return a small Object
*
* @param {EBookContacts.Contact} econtact - an EContact to parse
* @param {string} [origin] - an optional origin string
* @returns {Object} - a small JSON serializable object
*/
async _parseEContact(econtact, origin = 'desktop') {
try {
let 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
let nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
for (let attr of nums) {
let 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
let photo = econtact.photo;
if (photo) {
if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
let data = photo.get_inlined()[0];
contact.avatar = await this.storeAvatar(data);
} else if (photo.type === EBookContacts.ContactPhotoType.URI) {
let uri = econtact.photo.get_uri();
contact.avatar = uri.replace('file://', '');
}
}
return contact;
} catch (e) {
logError(e, `Failed to parse VCard contact ${econtact.id}`);
return undefined;
}
}
/*
* EDS Helpers
*/
_getEBookClient(source, cancellable = null) {
return new Promise((resolve, reject) => {
EBook.BookClient.connect(source, 0, cancellable, (source, res) => {
try {
resolve(EBook.BookClient.connect_finish(res));
} catch (e) {
reject(e);
}
});
});
}
_getEBookView(client, query = '', cancellable = null) {
return new Promise((resolve, reject) => {
client.get_view(query, cancellable, (client, res) => {
try {
resolve(client.get_view_finish(res)[1]);
} catch (e) {
reject(e);
}
});
});
}
_getEContacts(client, query = '', cancellable = null) {
return new Promise((resolve, reject) => {
client.get_contacts(query, cancellable, (client, res) => {
try {
resolve(client.get_contacts_finish(res)[1]);
} catch (e) {
debug(e);
reject([]);
}
});
});
}
_getESourceRegistry(cancellable = null) {
return new Promise ((resolve, reject) => {
EDataServer.SourceRegistry.new(cancellable, (registry, res) => {
try {
resolve(EDataServer.SourceRegistry.new_finish(res));
} catch (e) {
reject(e);
}
});
});
}
/*
* AddressBook DBus callbacks
*/
async _onObjectsAdded(connection, sender, path, iface, signal, params) {
try {
let 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 {
let vcard = adds[i];
let econtact = EBookContacts.Contact.new_from_vcard(vcard);
let contact = await this._parseEContact(econtact);
if (contact !== undefined) {
this.add(contact, false);
}
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
_onObjectsRemoved(connection, sender, path, iface, signal, params) {
try {
let changes = params.get_child_value(0).get_strv();
for (let id of changes) {
try {
this.remove(id, false);
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
async _onObjectsModified(connection, sender, path, iface, signal, params) {
try {
let 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 {
let vcard = changes[i];
let econtact = EBookContacts.Contact.new_from_vcard(vcard);
let contact = await this._parseEContact(econtact);
if (contact !== undefined) {
this.add(contact, false);
}
} catch (e) {
debug(e);
}
}
} catch (e) {
debug(e);
}
}
/*
* SourceRegistryWatcher callbacks
*/
async _onAppeared(watcher, source) {
try {
// Get an EBookClient and EBookView
let uid = source.get_uid();
let client = await this._getEBookClient(source);
let view = await this._getEBookView(client, 'exists "tel"');
// Watch the view for changes to the address book
let connection = view.get_connection();
let 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 {
let uid = source.get_uid();
let ebook = this._ebooks.get(uid);
if (ebook === undefined)
return;
// Disconnect and dispose the EBookView
if (ebook.view) {
let 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();
ebook.view.run_dispose();
ebook.view = null;
}
// Dispose the EBookClient
if (ebook.client) {
ebook.client.run_dispose();
ebook.client = null;
}
// Drop the EBook
this._ebooks.delete(uid);
} catch (e) {
debug(e);
}
}
async _initEvolutionDataServer() {
try {
// Get the current EBooks
let registry = await this._getESourceRegistry();
for (let 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) {
logError(e);
}
}
*[Symbol.iterator]() {
let contacts = this.contacts;
for (let i = 0, len = contacts.length; i < len; i++) {
yield contacts[i];
}
}
get contacts() {
return Object.values(this.__cache_data);
}
get context() {
if (this._context === undefined) {
this._context = null;
}
return this._context;
}
set context(context) {
this._context = context;
if (context === null) {
this.__cache_dir = Gio.File.new_for_path(gsconnect.cachedir);
} else {
this.__cache_dir = Gio.File.new_for_path(
GLib.build_filenamev([gsconnect.cachedir, context])
);
}
GLib.mkdir_with_parents(this.__cache_dir.get_path(), 448);
this.__cache_file = this.__cache_dir.get_child('contacts.json');
}
/**
* Save a ByteArray to file and return the path
*
* @param {ByteArray} contents - An image ByteArray
* @return {string|undefined} - File path or %undefined on failure
*/
storeAvatar(contents) {
return new Promise((resolve, reject) => {
let md5 = GLib.compute_checksum_for_data(
GLib.ChecksumType.MD5,
contents
);
let file = this.__cache_dir.get_child(`${md5}`);
if (file.query_exists(null)) {
resolve(file.get_path());
} else {
file.replace_contents_bytes_async(
new GLib.Bytes(contents),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null,
(file, res) => {
try {
file.replace_contents_finish(res);
resolve(file.get_path());
} catch (e) {
debug(e, 'Storing avatar');
resolve(undefined);
}
}
);
}
});
}
/**
* 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
let contacts = this.contacts;
let matches = [];
let qnumber = query.number.toPhoneNumber();
for (let i = 0, len = contacts.length; i < len; i++) {
let contact = contacts[i];
for (let num of contact.numbers) {
let 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.__cache_data.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) {
try {
return (this.__cache_data[position]) ? this.__cache_data[position] : null;
} catch (e) {
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.__cache_data[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.__cache_data[contact.id]) {
this.__cache_data[contact.id] = contact;
this.emit('contact-changed', contact.id);
// This is a new contact
} else {
this.__cache_data[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.__cache_data[id]) {
delete this.__cache_data[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 {Array of object} addresses - A list of address objects
* @return {object} - A dictionary of phone numbers and contacts
*/
lookupAddresses(addresses) {
let contacts = {};
// Lookup contacts for each address
for (let i = 0, len = addresses.length; i < len; i++) {
let address = addresses[i].address;
contacts[address] = this.query({
number: address
});
}
return contacts;
}
async clear() {
try {
let 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, 'Clearing contacts');
}
}
/**
* 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++) {
let new_contact = contacts[i];
let contact = this.__cache_data[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++) {
let 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
* simply logs a debug message if EDS is unavailable, while derived classes
* should request an update from the remote source.
*/
async fetch() {
try {
if (HAVE_EDS === false) {
throw new Error('Evolution Data Server not available');
}
} catch (e) {
debug(e);
}
}
/**
* Load the contacts from disk.
*/
async load() {
try {
this.__cache_data = await JSON.load(this.__cache_file);
} catch (e) {
debug(e);
} finally {
this.notify('context');
}
}
/**
* Save the contacts to disk.
*/
async save() {
if (this.context === null && HAVE_EDS) {
return;
}
if (this.__cache_lock) {
this.__cache_queue = true;
return;
}
try {
this.__cache_lock = true;
await JSON.dump(this.__cache_data, this.__cache_file);
} catch (e) {
debug(e);
} finally {
this.__cache_lock = false;
if (this.__cache_queue) {
this.__cache_queue = false;
this.save();
}
}
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
if (HAVE_EDS) {
this._watcher.disconnect(this._appearedId);
this._watcher.disconnect(this._disappearedId);
this._watcher.run_dispose();
for (let ebook of this._ebooks.values()) {
this._onDisappeared(null, ebook.source);
}
}
this.run_dispose();
}
}
});
/**
* The service class for this component
*/
var Component = Store;

View File

@@ -0,0 +1,567 @@
'use strict';
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
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.DO_NOT_LOAD_PROPERTIES
});
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;
this.call(name, parameters, Gio.DBusCallFlags.NONE, -1, null, null);
}
async start() {
try {
if (this._started) return;
// Initialize the proxy
await new Promise((resolve, reject) => {
this.init_async(
GLib.PRIORITY_DEFAULT,
null,
(proxy, res) => {
try {
proxy.init_finish(res);
resolve();
} catch (e) {
reject(e);
}
}
);
});
// Start the session
await new Promise((resolve, reject) => {
this.call(
'Start',
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(proxy, res) => {
try {
resolve(proxy.call_finish(res));
} catch (e) {
reject(e);
}
}
);
});
this._started = true;
} catch (e) {
this.destroy();
Gio.DBusError.strip_remote_error(e);
throw e;
}
}
stop() {
if (this._started) {
this._started = false;
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) {
// TODO: NotifyPointerAxis only seems to work on Wayland, but maybe
// NotifyPointerAxisDiscrete is the better choice anyways
if (_WAYLAND) {
this._call(
'NotifyPointerAxis',
GLib.Variant.new('(ddu)', [dx, dy, 0])
);
this._call(
'NotifyPointerAxis',
GLib.Variant.new('(ddu)', [0, 0, 1])
);
} else {
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') {
let 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;
this.run_dispose();
}
}
});
const Controller = 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;
}
_checkWayland() {
if (_WAYLAND) {
// eslint-disable-next-line no-global-assign
HAVE_REMOTEINPUT = false;
let service = Gio.Application.get_default();
// First we're going to disabled the mousepad plugin on all devices
for (let device of service.devices) {
let supported = device.settings.get_strv('supported-plugins');
supported = supported.splice(supported.indexOf('mousepad'), 1);
device.settings.set_strv('supported-plugins', supported);
}
// Second we need to amend the service identity and broadcast
service._identity = undefined;
service._identify();
return true;
}
return false;
}
_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
let 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;
}
_createSession() {
return new Promise((resolve, reject) => {
if (this.connection === null) {
reject(new Error('No DBus connection'));
return;
}
this.connection.call(
'org.gnome.Mutter.RemoteDesktop',
'/org/gnome/Mutter/RemoteDesktop',
'org.gnome.Mutter.RemoteDesktop',
'CreateSession',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
}
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');
// If we got here in Wayland, we need to re-adjust and bail
if (this._checkWayland()) return;
let fallback = imports.service.components.atspi;
this._session = new fallback.Controller();
// Mutter is available and there isn't another session starting
} else if (this._sessionStarting === false) {
this._sessionStarting = true;
debug('Creating Mutter RemoteDesktop session');
let objectPath = await this._createSession();
this._session = new RemoteSession(objectPath);
await this._session.start();
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
*/
pressKey(input, modifiers) {
try {
this._ensureAdapter();
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;
}
}
};
/**
* The service class for this component
*/
var Component = Controller;

View File

@@ -0,0 +1,781 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
var Player = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlayer',
Implements: [Gio.DBusInterface],
Properties: {
// Application Properties
'CanQuit': GObject.ParamSpec.boolean(
'CanQuit',
'Can Quit',
'Whether the client can call the Quit method.',
GObject.ParamFlags.READABLE,
false
),
'Fullscreen': GObject.ParamSpec.boolean(
'Fullscreen',
'Fullscreen',
'Whether the player is in fullscreen mode.',
GObject.ParamFlags.READWRITE,
false
),
'CanSetFullscreen': GObject.ParamSpec.boolean(
'CanSetFullscreen',
'Can Set Fullscreen',
'Whether the client can set the Fullscreen property.',
GObject.ParamFlags.READABLE,
false
),
'CanRaise': GObject.ParamSpec.boolean(
'CanRaise',
'Can Raise',
'Whether the client can call the Raise method.',
GObject.ParamFlags.READABLE,
false
),
'HasTrackList': GObject.ParamSpec.boolean(
'HasTrackList',
'Has Track List',
'Whether the player has a track list.',
GObject.ParamFlags.READABLE,
false
),
'Identity': GObject.ParamSpec.string(
'Identity',
'Identity',
'The application name.',
GObject.ParamFlags.READABLE,
null
),
'DesktopEntry': GObject.ParamSpec.string(
'DesktopEntry',
'DesktopEntry',
'The basename of an installed .desktop file.',
GObject.ParamFlags.READABLE,
null
),
'SupportedUriSchemes': GObject.param_spec_variant(
'SupportedUriSchemes',
'Supported URI Schemes',
'The URI schemes supported by the media player.',
new GLib.VariantType('as'),
null,
GObject.ParamFlags.READABLE
),
'SupportedMimeTypes': GObject.param_spec_variant(
'SupportedMimeTypes',
'Supported MIME Types',
'The mime-types supported by the media player.',
new GLib.VariantType('as'),
null,
GObject.ParamFlags.READABLE
),
// Player Properties
'PlaybackStatus': GObject.ParamSpec.string(
'PlaybackStatus',
'Playback Status',
'The current playback status.',
GObject.ParamFlags.READABLE,
null
),
'LoopStatus': GObject.ParamSpec.string(
'LoopStatus',
'Loop Status',
'The current loop status.',
GObject.ParamFlags.READWRITE,
null
),
'Rate': GObject.ParamSpec.double(
'Rate',
'Rate',
'The current playback rate.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'MinimumRate': GObject.ParamSpec.double(
'MinimumRate',
'Minimum Rate',
'The minimum playback rate.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'MaximimRate': GObject.ParamSpec.double(
'MaximumRate',
'Maximum Rate',
'The maximum playback rate.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'Shuffle': GObject.ParamSpec.boolean(
'Shuffle',
'Shuffle',
'Whether track changes are linear.',
GObject.ParamFlags.READWRITE,
null
),
'Metadata': GObject.param_spec_variant(
'Metadata',
'Metadata',
'The metadata of the current element.',
new GLib.VariantType('a{sv}'),
null,
GObject.ParamFlags.READABLE
),
'Volume': GObject.ParamSpec.double(
'Volume',
'Volume',
'The volume level.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'Position': GObject.ParamSpec.int64(
'Position',
'Position',
'The current track position in microseconds.',
GObject.ParamFlags.READABLE,
0, Number.MAX_SAFE_INTEGER,
0
),
'CanGoNext': GObject.ParamSpec.boolean(
'CanGoNext',
'Can Go Next',
'Whether the client can call the Next method.',
GObject.ParamFlags.READABLE,
false
),
'CanGoPrevious': GObject.ParamSpec.boolean(
'CanGoPrevious',
'Can Go Previous',
'Whether the client can call the Previous method.',
GObject.ParamFlags.READABLE,
false
),
'CanPlay': GObject.ParamSpec.boolean(
'CanPlay',
'Can Play',
'Whether playback can be started using Play or PlayPause.',
GObject.ParamFlags.READABLE,
false
),
'CanPause': GObject.ParamSpec.boolean(
'CanPause',
'Can Pause',
'Whether playback can be paused using Play or PlayPause.',
GObject.ParamFlags.READABLE,
false
),
'CanSeek': GObject.ParamSpec.boolean(
'CanSeek',
'Can Seek',
'Whether the client can control the playback position using Seek and SetPosition.',
GObject.ParamFlags.READABLE,
false
),
'CanControl': GObject.ParamSpec.boolean(
'CanControl',
'Can Control',
'Whether the media player may be controlled over this interface.',
GObject.ParamFlags.READABLE,
false
)
},
Signals: {
'Seeked': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_INT64]
}
}
}, class Player extends Gio.DBusProxy {
_init(name) {
super._init({
g_bus_type: Gio.BusType.SESSION,
g_name: name,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_name: 'org.mpris.MediaPlayer2.Player'
});
this._application = new Gio.DBusProxy({
g_bus_type: Gio.BusType.SESSION,
g_name: name,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_name: 'org.mpris.MediaPlayer2'
});
this._propertiesChangedId = this._application.connect(
'g-properties-changed',
this._onPropertiesChanged.bind(this)
);
this._cancellable = new Gio.Cancellable();
}
vfunc_g_properties_changed(changed, invalidated) {
try {
if (this.__disposed !== undefined)
return;
for (let name in changed.deepUnpack()) {
this.notify(name);
}
} catch (e) {
debug(e, this.g_name);
}
}
vfunc_g_signal(sender_name, signal_name, parameters) {
try {
if (signal_name === 'Seeked') {
this.emit('Seeked', parameters.deepUnpack()[0]);
}
} catch (e) {
debug(e, this.g_name);
}
}
_call(name, parameters = null) {
this.call(
name,
parameters,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
this._cancellable,
(proxy, result) => {
try {
proxy.call_finish(result);
} catch (e) {
Gio.DBusError.strip_remote_error(e);
logError(e, this.g_name);
}
}
);
}
_get(name, fallback = null) {
try {
return this.get_cached_property(name).recursiveUnpack();
} catch (e) {
return fallback;
}
}
_set(name, value) {
try {
this.set_cached_property(name, value);
this.call(
'org.freedesktop.DBus.Properties.Set',
new GLib.Variant('(ssv)', [this.g_interface_name, name, value]),
Gio.DBusCallFlags.NO_AUTO_START,
-1,
this._cancellable,
(proxy, result) => {
try {
proxy.call_finish(result);
} catch (e) {
logError(e);
}
}
);
} catch (e) {
logError(e, this.g_name);
}
}
_onPropertiesChanged(proxy, changed, invalidated) {
try {
if (this.__disposed !== undefined)
return;
for (let name in changed.deepUnpack()) {
this.notify(name);
}
} catch (e) {
logError(e, this.g_name);
}
}
initPromise() {
let player = new Promise((resolve, reject) => {
this.init_async(0, this._cancellable, (proxy, res) => {
try {
resolve(proxy.init_finish(res));
} catch (e) {
reject(e);
}
});
});
let application = new Promise((resolve, reject) => {
this._application.init_async(0, this._cancellable, (proxy, res) => {
try {
resolve(proxy.init_finish(res));
} catch (e) {
reject(e);
}
});
});
return Promise.all([player, application]);
}
/*
* The org.mpris.MediaPlayer2 Interface
*/
get CanQuit() {
return this._get.call(this._application, 'CanQuit', false);
}
get Fullscreen() {
return this._get.call(this._application, 'Fullscreen', false);
}
set Fullscreen(mode) {
this._set.call(this._application, 'Fullscreen', new GLib.Variant('b', mode));
}
get CanSetFullscreen() {
return this._get.call(this._application, 'CanSetFullscreen', false);
}
get CanRaise() {
return this._get.call(this._application, 'CanRaise', false);
}
get HasTrackList() {
return this._get.call(this._application, 'HasTrackList', false);
}
get Identity() {
return this._get.call(this._application, 'Identity', _('Unknown'));
}
get DesktopEntry() {
return this._get.call(this._application, 'DesktopEntry', null);
}
get SupportedUriSchemes() {
return this._get.call(this._application, 'SupportedUriSchemes', []);
}
get SupportedMimeTypes() {
return this._get.call(this._application, 'SupportedMimeTypes', []);
}
Raise() {
this._call.call(this._application, 'Raise');
}
Quit() {
this._call.call(this._application, 'Quit');
}
/*
* The org.mpris.MediaPlayer2.Player Interface
*/
get PlaybackStatus() {
return this._get('PlaybackStatus', 'Stopped');
}
// 'None', 'Track', 'Playlist'
get LoopStatus() {
return this._get('LoopStatus', 'None');
}
set LoopStatus(status) {
this._set('LoopStatus', new GLib.Variant('s', status));
}
get Rate() {
return this._get('Rate', 1.0);
}
set Rate(rate) {
this._set('Rate', new GLib.Variant('d', rate));
}
get Shuffle() {
return this._get('Shuffle', false);
}
set Shuffle(mode) {
this._set('Shuffle', new GLib.Variant('b', mode));
}
get Metadata() {
if (this._metadata == undefined) {
this._metadata = {
'xesam:artist': [_('Unknown')],
'xesam:album': _('Unknown'),
'xesam:title': _('Unknown'),
'mpris:length': 0
};
}
return this._get('Metadata', this._metadata);
}
get Volume() {
return this._get('Volume', 1.0);
}
set Volume(level) {
this._set('Volume', new GLib.Variant('d', level));
}
// g-properties-changed is not emitted for this property
get Position() {
try {
let reply = this.call_sync(
'org.freedesktop.DBus.Properties.Get',
new GLib.Variant('(ss)', [this.g_interface_name, 'Position']),
Gio.DBusCallFlags.NONE,
-1,
null
);
return reply.recursiveUnpack()[0];
} catch (e) {
return 0;
}
}
get MinimumRate() {
return this._get('MinimumRate', 1.0);
}
get MaximumRate() {
return this._get('MaximumRate', 1.0);
}
get CanGoNext() {
return this._get('CanGoNext', false);
}
get CanGoPrevious() {
return this._get('CanGoPrevious', false);
}
get CanPlay() {
return this._get('CanPlay', false);
}
get CanPause() {
return this._get('CanPause', false);
}
get CanSeek() {
return this._get('CanSeek', false);
}
get CanControl() {
return this._get('CanControl', false);
}
Next() {
this._call('Next');
}
Previous() {
this._call('Previous');
}
Pause() {
this._call('Pause');
}
PlayPause() {
this._call('PlayPause');
}
Stop() {
this._call('Stop');
}
Play() {
this._call('Play');
}
Seek(offset) {
this._call('Seek', new GLib.Variant('(x)', [offset]));
}
SetPosition(trackId, position) {
this._call('SetPosition', new GLib.Variant('(ox)', [trackId, position]));
}
OpenUri(uri) {
this._call('OpenUri', new GLib.Variant('(s)', [uri]));
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
this._cancellable.cancel();
this._application.disconnect(this._propertiesChangedId);
this._application.run_dispose();
this.run_dispose();
}
}
});
var Manager = GObject.registerClass({
GTypeName: 'GSConnectMPRISManager',
Implements: [Gio.DBusInterface],
Properties: {
'identities': GObject.param_spec_variant(
'identities',
'IdentityList',
'A list of MediaPlayer2.Identity for each player',
new GLib.VariantType('as'),
null,
GObject.ParamFlags.READABLE
),
// Actually returns an Object of MediaPlayer2Proxy objects,
// Player.Identity as key
'players': GObject.param_spec_variant(
'players',
'PlayerList',
'A list of known devices',
new GLib.VariantType('a{sv}'),
null,
GObject.ParamFlags.READABLE
)
},
Signals: {
'player-changed': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_OBJECT]
},
'player-seeked': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_OBJECT]
}
}
}, class Manager extends Gio.DBusProxy {
_init() {
super._init({
g_bus_type: Gio.BusType.SESSION,
g_name: 'org.freedesktop.DBus',
g_object_path: '/org/freedesktop/DBus'
});
// Asynchronous setup
this._cancellable = new Gio.Cancellable();
this._init_async();
}
async _init_async() {
try {
await new Promise((resolve, reject) => {
this.init_async(0, this._cancellable, (proxy, res) => {
try {
resolve(proxy.init_finish(res));
} catch (e) {
reject(e);
}
});
});
let names = await new Promise((resolve, reject) => {
this.call(
'org.freedesktop.DBus.ListNames',
null,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
this._cancellable,
(proxy, res) => {
try {
resolve(proxy.call_finish(res).deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
for (let i = 0, len = names.length; i < len; i++) {
let name = names[i];
if (name.startsWith('org.mpris.MediaPlayer2') &&
!name.includes('GSConnect')) {
this._addPlayer(name);
}
}
} catch (e) {
// FIXME: if something goes wrong the component will appear active
logError(e);
this.destroy();
}
}
get identities() {
let identities = [];
for (let player of this.players.values()) {
let identity = player.Identity;
if (identity)
identities.push(identity);
}
return identities;
}
get players() {
if (this._players === undefined) {
this._players = new Map();
}
return this._players;
}
get paused() {
if (this._paused === undefined) {
this._paused = new Map();
}
return this._paused;
}
vfunc_g_signal(sender_name, signal_name, parameters) {
try {
if (signal_name === 'NameOwnerChanged') {
let [name, old_owner, new_owner] = parameters.deepUnpack();
if (name.startsWith('org.mpris.MediaPlayer2') &&
!name.includes('GSConnect')) {
if (new_owner.length) {
this._addPlayer(name);
} else if (old_owner.length) {
this._removePlayer(name);
}
}
}
} catch (e) {
debug(e);
}
}
async _addPlayer(name) {
try {
if (!this.players.has(name)) {
let player = new Player(name);
await player.initPromise();
player.__propertiesId = player.connect(
'g-properties-changed',
(player) => this.emit('player-changed', player)
);
player.__seekedId = player.connect(
'Seeked',
(player) => this.emit('player-seeked', player)
);
this.players.set(name, player);
this.notify('players');
}
} catch (e) {
debug(e);
}
}
async _removePlayer(name) {
try {
let player = this.players.get(name);
if (player !== undefined) {
debug(`Removing MPRIS Player ${name}`);
player.disconnect(player.__propertiesId);
player.disconnect(player.__seekedId);
player.destroy();
this.paused.delete(name);
this.players.delete(name);
this.notify('players');
}
} catch (e) {
debug(e);
}
}
/**
* Get a player by its Identity.
*/
getPlayer(identity) {
for (let player of this.players.values()) {
if (player.Identity === identity) {
return player;
}
}
return null;
}
/**
* A convenience function for pausing all players currently playing.
*/
pauseAll() {
for (let [name, player] of this.players.entries()) {
if (player.PlaybackStatus === 'Playing' && player.CanPause) {
player.Pause();
this.paused.set(name, player);
}
}
}
/**
* A convenience function for restarting all players paused with pauseAll().
*/
unpauseAll() {
for (let player of this.paused.values()) {
if (player.PlaybackStatus === 'Paused' && player.CanPlay) {
player.Play();
}
}
this.paused.clear();
}
destroy() {
if (this.__disposed == undefined) {
this.__disposed = true;
this._cancellable.cancel();
for (let player of this.players.values()) {
player.disconnect(player.__propertiesId);
player.disconnect(player.__seekedId);
player.destroy();
}
this.players.clear();
this.run_dispose();
}
}
});
/**
* The service class for this component
*/
var Component = Manager;

View File

@@ -0,0 +1,420 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GjsPrivate = imports.gi.GjsPrivate;
const DBus = imports.utils.dbus;
let _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.
*/
var Listener = class Listener {
constructor() {
// 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)
);
this._onSettingsChanged();
// Cache for appName->desktop-id lookups
this._names = {};
// Asynchronous setup
this._init_async();
}
get application() {
return Gio.Application.get_default();
}
get applications() {
if (this._applications === undefined) {
this._applications = {};
}
return this._applications;
}
/**
* Update application notification settings
*/
_onSettingsChanged() {
this._applications = {};
for (let app of this._settings.get_strv('application-children')) {
let appSettings = new Gio.Settings({
schema_id: 'org.gnome.desktop.notifications.application',
path: `/org/gnome/desktop/notifications/application/${app}/`
});
let appInfo = Gio.DesktopAppInfo.new(
appSettings.get_string('application-id')
);
if (appInfo !== null) {
this._applications[appInfo.get_name()] = appSettings;
}
}
}
_listNames() {
return new Promise((resolve, reject) => {
this._session.call(
'org.freedesktop.DBus',
'/org/freedesktop/DBus',
'org.freedesktop.DBus',
'ListNames',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
}
_getNameOwner(name) {
return new Promise((resolve, reject) => {
this._session.call(
'org.freedesktop.DBus',
'/org/freedesktop/DBus',
'org.freedesktop.DBus',
'GetNameOwner',
new GLib.Variant('(s)', [name]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
}
/**
* 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
let names = await this._listNames();
names.splice(names.indexOf(sender), 1);
// Make a short list for substring matches (fractal/org.gnome.Fractal)
let appLower = appName.toLowerCase();
let shortList = names.filter(name => {
return name.toLowerCase().includes(appLower);
});
// Run the short list first
for (let name of shortList) {
let nameOwner = await this._getNameOwner(name);
if (nameOwner === sender) {
return name;
}
names.splice(names.indexOf(name), 1);
}
// Run the full list
for (let name of names) {
let 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 - (Optional) appName supplied by Notify()
* @return {string} - A well-known name or %null
*/
async _getAppName(sender, appName) {
// Check the cache first
if (appName && this._names.hasOwnProperty(appName)) {
return this._names[appName];
}
let appId, appInfo;
try {
appId = await this._getAppId(sender, appName);
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()
*/
async _onHandleMethodCall(impl, 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') {
// Try to brute-force an application name using DBus
if (!this.applications.hasOwnProperty(parameters[0])) {
let sender = invocation.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
*/
_monitorConnection() {
return new Promise((resolve, reject) => {
// 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'
);
// 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
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,
(connection, res) => {
try {
resolve(connection.call_finish(res));
} catch (e) {
reject(e);
}
}
);
});
}
async _init_async() {
try {
this._session = await DBus.getConnection();
this._monitor = await DBus.newConnection();
await this._monitorConnection();
} catch (e) {
// FIXME: if something goes wrong the component will appear active
logError(e);
this.destroy();
}
}
_sendNotification(notif) {
// Check if this application is disabled in desktop settings
let appSettings = this.applications[notif.appName];
if (appSettings && !appSettings.get_boolean('enable')) {
return;
}
// Send the notification to each supporting device
let variant = GLib.Variant.full_pack(notif);
for (let device of this.application._devices.values()) {
device.activate_action('sendNotification', variant);
}
}
Notify(appName, replacesId, iconName, summary, body, actions, hints, timeout) {
try {
// 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
});
} catch (e) {
debug(e);
}
}
AddNotification(application, id, notification) {
try {
// Ignore our own GNotifications
if (application === 'org.gnome.Shell.Extensions.GSConnect') {
return;
}
let 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
});
} catch (e) {
debug(e);
}
}
destroy() {
try {
if (this._fdoNotifications) {
this._fdoNotifications.disconnect(this._fdoMethodCallId);
this._fdoNotifications.flush();
this._fdoNotifications.unexport();
}
if (this._gtkNotifications) {
this._gtkNotifications.disconnect(this._gtkMethodCallId);
this._gtkNotifications.flush();
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);
} catch (e) {
debug(e);
}
}
};
/**
* The service class for this component
*/
var Component = Listener;

View File

@@ -0,0 +1,253 @@
'use strict';
const Tweener = imports.tweener.tweener;
const Gio = imports.gi.Gio;
const GIRepository = imports.gi.GIRepository;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
// Add gnome-shell's typelib dir to the search path
let typelibDir = GLib.build_filenamev([gsconnect.libdir, 'gnome-shell']);
GIRepository.Repository.prepend_search_path(typelibDir);
GIRepository.Repository.prepend_library_path(typelibDir);
const Gvc = imports.gi.Gvc;
/**
* Extend Gvc.MixerStream with a property for returning a user-visible name
*/
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);
this._mixer.fading = true;
if (this._stream.volume > value) {
Tweener.addTween(this, {
volume: value,
time: duration,
transition: 'easeOutCubic',
onComplete: () => this._mixer.fading = false
});
} else if (this._stream.volume < value) {
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.
*/
var Mixer = GObject.registerClass({
GTypeName: 'GSConnectAudioMixer'
}, class Mixer extends Gvc.MixerControl {
_init(params) {
super._init({name: 'GSConnect'});
this.open();
this._previousVolume = undefined;
this._volumeMuted = false;
this._microphoneMuted = false;
}
get fading() {
if (this._fading === undefined) {
this._fading = false;
}
return this._fading;
}
set fading(bool) {
if (this.fading !== bool) {
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 {
let sink = this.get_default_sink();
this._output = (sink) ? new Stream(this, sink) : null;
} catch (e) {
logError(e);
}
}
vfunc_default_source_changed(id) {
try {
let 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
*/
lowerVolume(duration = 1) {
try {
if (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.muted) {
this.output.muted = true;
this._volumeMuted = true;
}
} catch (e) {
logError(e);
}
}
/**
* Mute the input volume (microphone)
*/
muteMicrophone() {
try {
if (!this.input.muted) {
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() {
}
});
/**
* The service class for this component
*/
var Component = Mixer;

View File

@@ -0,0 +1,120 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Session = class {
constructor() {
this._connection = Gio.DBus.system;
this._session = null;
this._initAsync();
}
async _initAsync() {
try {
let userName = GLib.get_user_name();
let sessions = await this._listSessions();
let sessionPath = '/org/freedesktop/login1/session/auto';
// eslint-disable-next-line no-unused-vars
for (let [num, uid, name, seat, objectPath] of sessions) {
if (name === userName) {
sessionPath = objectPath;
break;
}
}
this._session = await this._getSession(sessionPath);
} 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);
}
_listSessions() {
return new Promise((resolve, reject) => {
this._connection.call(
'org.freedesktop.login1',
'/org/freedesktop/login1',
'org.freedesktop.login1.Manager',
'ListSessions',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
}
async _getSession(objectPath) {
let session = new Gio.DBusProxy({
g_connection: this._connection,
g_name: 'org.freedesktop.login1',
g_object_path: objectPath,
g_interface_name: 'org.freedesktop.login1.Session'
});
// Initialize the proxy
await new Promise((resolve, reject) => {
session.init_async(
GLib.PRIORITY_DEFAULT,
null,
(proxy, res) => {
try {
resolve(proxy.init_finish(res));
} catch (e) {
Gio.DBusError.strip_remote_error(e);
reject(e);
}
}
);
});
return session;
}
destroy() {
if (this._session !== null) {
this._session.run_dispose();
}
}
};
/**
* The service class for this component
*/
var Component = Session;

View File

@@ -0,0 +1,201 @@
'use strict';
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
/**
* Used to ensure 'audible-bell' is enabled for fallback
*/
const WM_SETTINGS = new Gio.Settings({
schema_id: 'org.gnome.desktop.wm.preferences',
path: '/org/gnome/desktop/wm/preferences/'
});
var Sound = class Sound {
constructor() {
}
get backend() {
if (this._backend === undefined) {
// Prefer GSound
try {
this._gsound = new imports.gi.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
} catch (e) {
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;
}
get playing() {
if (this._playing === undefined) {
this._playing = new Set();
}
return this._playing;
}
_canberraPlaySound(name, cancellable) {
return new Promise((resolve, reject) => {
let proc = this._canberra.spawnv(['canberra-gtk-play', '-i', name]);
proc.wait_check_async(cancellable, (proc, res) => {
try {
resolve(proc.wait_check_finish(res));
} catch (e) {
reject(e);
}
});
});
}
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.code || e.code !== 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.code || e.code !== Gio.IOErrorEnum.CANCELLED) {
logError(e);
}
} finally {
this.playing.delete(cancellable);
}
}
destroy() {
for (let cancellable of this.playing) {
cancellable.cancel();
}
}
};
/**
* The service class for this component
*/
var Component = Sound;

View File

@@ -0,0 +1,62 @@
'use strict';
const GObject = imports.gi.GObject;
const UPower = imports.gi.UPowerGlib;
var Battery = GObject.registerClass({
GTypeName: 'GSConnectSystemBattery',
Signals: {
'changed': {flags: GObject.SignalFlags.RUN_FIRST}
}
}, class Battery extends UPower.Device {
_init() {
super._init();
// This may throw an exception?
this.set_object_path_sync(
'/org/freedesktop/UPower/devices/DisplayDevice',
null
);
}
vfunc_notify(pspec) {
try {
switch (pspec.get_name()) {
case 'percentage':
case 'state':
case 'warning-level':
this.emit('changed');
}
} catch (e) {
}
}
get charging() {
return (this.state !== UPower.DeviceState.DISCHARGING);
}
get level() {
return this.percentage;
}
// TODO: reset on charging
get threshold() {
if (!this.charging && this.warning_level >= UPower.DeviceLevel.LOW) {
return 1;
} else {
return 0;
}
}
destroy() {
}
});
/**
* The service class for this component
*/
var Component = Battery;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env gjs
'use strict';
imports.gi.versions.Gio = '2.0';
imports.gi.versions.GLib = '2.0';
imports.gi.versions.GObject = '2.0';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const System = imports.system;
var NativeMessagingHost = GObject.registerClass({
GTypeName: 'GSConnectNativeMessagingHost'
}, class NativeMessagingHost extends Gio.Application {
_init() {
super._init({
application_id: 'org.gnome.Shell.Extensions.GSConnect.NativeMessagingHost',
flags: Gio.ApplicationFlags.NON_UNIQUE
});
}
get devices() {
if (this._devices === undefined) {
this._devices = {};
}
return this._devices;
}
vfunc_activate() {
super.vfunc_activate();
}
vfunc_startup() {
super.vfunc_startup();
this.hold();
// IO Channels
this.stdin = new Gio.DataInputStream({
base_stream: new Gio.UnixInputStream({fd: 0}),
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN
});
this.stdout = new Gio.DataOutputStream({
base_stream: new Gio.UnixOutputStream({fd: 1}),
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN
});
let source = this.stdin.base_stream.create_source(null);
source.set_callback(this.receive.bind(this));
source.attach(null);
this._init_async();
}
async _init_async(obj, res) {
try {
this.manager = await new Promise((resolve, reject) => {
Gio.DBusObjectManagerClient.new_for_bus(
Gio.BusType.SESSION,
Gio.DBusObjectManagerClientFlags.DO_NOT_AUTO_START,
'org.gnome.Shell.Extensions.GSConnect',
'/org/gnome/Shell/Extensions/GSConnect',
null,
null,
(manager, res) => {
try {
resolve(Gio.DBusObjectManagerClient.new_for_bus_finish(res));
} catch (e) {
reject(e);
}
}
);
});
// Add currently managed devices
for (let object of this.manager.get_objects()) {
for (let iface of object.get_interfaces()) {
this._onInterfaceAdded(this.manager, object, iface);
}
}
// Watch for new and removed devices
this.manager.connect(
'interface-added',
this._onInterfaceAdded.bind(this)
);
this.manager.connect(
'object-removed',
this._onObjectRemoved.bind(this)
);
// Watch for device property changes
this.manager.connect(
'interface-proxy-properties-changed',
this.sendDeviceList.bind(this)
);
// Watch for service restarts
this.manager.connect(
'notify::name-owner',
this.sendDeviceList.bind(this)
);
this.send({type: 'connected', data: true});
} catch (e) {
this.quit();
}
}
receive() {
try {
// Read the message
let length = this.stdin.read_int32(null);
let bytes = this.stdin.read_bytes(length, null).toArray();
let message = JSON.parse(imports.byteArray.toString(bytes));
// A request for a list of devices
if (message.type === 'devices') {
this.sendDeviceList();
// A request to invoke an action
} else if (message.type === 'share') {
let actionName;
let device = this.devices[message.data.device];
if (device) {
if (message.data.action === 'share') {
actionName = 'shareUri';
} else if (message.data.action === 'telephony') {
actionName = 'shareSms';
}
device.actions.activate_action(
actionName,
new GLib.Variant('s', message.data.url)
);
}
}
return true;
} catch (e) {
this.quit();
}
}
send(message) {
try {
let data = JSON.stringify(message);
this.stdout.put_int32(data.length, null);
this.stdout.put_string(data, null);
} catch (e) {
this.quit();
}
}
sendDeviceList() {
// Inform the WebExtension we're disconnected from the service
if (this.manager && this.manager.name_owner === null) {
this.send({type: 'connected', data: false});
return;
}
let available = [];
for (let device of Object.values(this.devices)) {
let share = device.actions.get_action_enabled('shareUri');
let telephony = device.actions.get_action_enabled('shareSms');
if (share || telephony) {
available.push({
id: device.g_object_path,
name: device.name,
type: device.type,
share: share,
telephony: telephony
});
}
}
this.send({type: 'devices', data: available});
}
_proxyGetter(name) {
try {
return this.get_cached_property(name).unpack();
} catch (e) {
return null;
}
}
_onInterfaceAdded(manager, object, iface) {
Object.defineProperties(iface, {
'name': {
get: this._proxyGetter.bind(iface, 'Name'),
enumerable: true
},
// TODO: phase this out for icon-name
'type': {
get: this._proxyGetter.bind(iface, 'Type'),
enumerable: true
}
});
iface.actions = Gio.DBusActionGroup.get(
iface.g_connection,
iface.g_name,
iface.g_object_path
);
this.devices[iface.g_object_path] = iface;
this.sendDeviceList();
}
_onObjectRemoved(manager, object) {
delete this.devices[object.g_object_path];
this.sendDeviceList();
}
});
// NOTE: must not pass ARGV
(new NativeMessagingHost()).run([System.programInvocationName]);

View File

@@ -0,0 +1,244 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
/**
* Base class for plugins
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectDevicePlugin'
}, class Plugin extends GObject.Object {
_init(device, name) {
super._init();
this._device = device;
this._name = name;
this._meta = imports.service.plugins[name].Metadata;
// GSettings
this.settings = new Gio.Settings({
settings_schema: gsconnect.gschema.lookup(this._meta.id, false),
path: `${device.settings.path}plugin/${name}/`
});
// GActions
this._gactions = [];
if (this._meta.actions) {
let menu = this.device.settings.get_strv('menu-actions');
for (let name in this._meta.actions) {
let meta = this._meta.actions[name];
this._registerAction(name, menu.indexOf(name), meta);
}
}
}
get device() {
return this._device;
}
get name() {
return this._name;
}
get service() {
return Gio.Application.get_default();
}
_activateAction(action, parameter = null) {
try {
if (parameter instanceof GLib.Variant) {
parameter = parameter.full_unpack();
}
if (Array.isArray(parameter)) {
this[action.name].apply(this, parameter);
} else {
this[action.name].call(this, parameter);
}
} catch (e) {
logError(e, action.name);
}
}
_registerAction(name, menuIndex, meta) {
try {
// Device Action
let action = new Gio.SimpleAction({
name: name,
parameter_type: meta.parameter_type,
enabled: false
});
action.connect('activate', this._activateAction.bind(this));
this.device.add_action(action);
// Menu
if (menuIndex > -1) {
this.device.addMenuAction(
action,
menuIndex,
meta.label,
meta.icon_name
);
}
this._gactions.push(action);
} catch (e) {
logError(e, `${this.device.name}: ${this.name}`);
}
}
/**
* This is called when a packet is received the plugin is a handler for
*
* @param {object} packet - A KDE Connect packet
*/
handlePacket(packet) {
throw new GObject.NotImplementedError();
}
/**
* These two methods are called by the device in response to the connection
* state changing.
*/
connected() {
// Enabled based on device capabilities, which might change
let incoming = this.device.settings.get_strv('incoming-capabilities');
let outgoing = this.device.settings.get_strv('outgoing-capabilities');
for (let action of this._gactions) {
let meta = this._meta.actions[action.name];
if (meta.incoming.every(type => outgoing.includes(type)) &&
meta.outgoing.every(type => incoming.includes(type))) {
action.set_enabled(true);
}
}
}
disconnected() {
for (let action of this._gactions) {
action.set_enabled(false);
}
}
/**
* Cache JSON parseable properties on this object for persistence. The
* filename ~/.cache/gsconnect/<device-id>/<plugin-name>.json will be used
* to store the properties and values.
*
* Calling cacheProperties() opens a JSON cache file and reads any stored
* properties and values onto the current instance. When destroy()
* is called the properties are automatically stored in the same file.
*
* @param {Array} names - A list of this object's property names to cache
*/
async cacheProperties(names) {
try {
this.__cache_properties = names;
// Ensure the device's cache directory exists
let cachedir = GLib.build_filenamev([
gsconnect.cachedir,
this.device.id
]);
GLib.mkdir_with_parents(cachedir, 448);
this.__cache_file = Gio.File.new_for_path(
GLib.build_filenamev([cachedir, `${this.name}.json`])
);
// Read the cache from disk
let cache = await JSON.load(this.__cache_file);
Object.assign(this, cache);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
} finally {
this.cacheLoaded();
}
}
/**
* An overridable function that is invoked when the on-disk cache is being
* cleared. Implementations should use this function to clear any in-memory
* cache data.
*/
clearCache() {}
/**
* An overridable function that is invoked when the cache is done loading
*/
cacheLoaded() {}
/**
* Write the plugin's cache to disk
*/
async __cache_write() {
if (this.__cache_lock) {
this.__cache_queue = true;
return;
}
try {
this.__cache_lock = true;
// Build the cache
let cache = {};
for (let name of this.__cache_properties) {
cache[name] = this[name];
}
await JSON.dump(cache, this.__cache_file);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
} finally {
this.__cache_lock = false;
if (this.__cache_queue) {
this.__cache_queue = false;
this.__cache_write();
}
}
}
/**
* Unregister plugin actions, write the cache (if applicable) and destroy
* any dangling signal handlers.
*/
destroy() {
for (let action of this._gactions) {
this.device.removeMenuAction(`device.${action.name}`);
this.device.remove_action(action.name);
action.run_dispose();
}
// Write the cache to disk synchronously
if (this.__cache_file && !this.__cache_lock) {
try {
// Build the cache
let cache = {};
for (let name of this.__cache_properties) {
cache[name] = this[name];
}
JSON.dump(cache, this.__cache_file, true);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
}
}
// Try to avoid any cyclic references from signal handlers
this.settings.run_dispose();
this.run_dispose();
}
});

View File

@@ -0,0 +1,402 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Battery'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Battery',
incomingCapabilities: ['kdeconnect.battery', 'kdeconnect.battery.request'],
outgoingCapabilities: ['kdeconnect.battery', 'kdeconnect.battery.request'],
actions: {}
};
/**
* Battery Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/battery
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectBatteryPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'battery');
// Setup Cache; defaults are 90 minute charge, 1 day discharge
this._chargeState = [54, 0, -1];
this._dischargeState = [864, 0, -1];
this._thresholdLevel = 25;
this.cacheProperties([
'_chargeState',
'_dischargeState',
'_thresholdLevel'
]);
// Export battery state as GAction
this.__state = new Gio.SimpleAction({
name: 'battery',
parameter_type: new GLib.VariantType('(bsii)'),
state: this.state
});
this.device.add_action(this.__state);
// Local Battery (UPower)
this._upowerId = 0;
this._sendStatisticsId = this.settings.connect(
'changed::send-statistics',
this._onSendStatisticsChanged.bind(this)
);
this._onSendStatisticsChanged(this.settings);
}
get charging() {
if (this._charging === undefined) {
this._charging = false;
}
return this._charging;
}
get icon_name() {
let icon;
if (this.level === -1) {
return 'battery-missing-symbolic';
} else if (this.level === 100) {
return 'battery-full-charged-symbolic';
} else if (this.level < 3) {
icon = 'battery-empty';
} else if (this.level < 10) {
icon = 'battery-caution';
} else if (this.level < 30) {
icon = 'battery-low';
} else if (this.level < 60) {
icon = 'battery-good';
} else if (this.level >= 60) {
icon = 'battery-full';
}
icon += this.charging ? '-charging-symbolic' : '-symbolic';
return icon;
}
get level() {
// This is what KDE Connect returns if the remote battery plugin is
// disabled or still being loaded
if (this._level === undefined) {
this._level = -1;
}
return this._level;
}
get time() {
if (this._time === undefined) {
this._time = 0;
}
return this._time;
}
get state() {
return new GLib.Variant(
'(bsii)',
[this.charging, this.icon_name, this.level, this.time]
);
}
clearCache() {
this._chargeState = [54, 0, -1];
this._dischargeState = [864, 0, -1];
this._thresholdLevel = 25;
this._estimateTime();
this.__cache_write();
}
cacheLoaded() {
this._estimateTime();
this.connected();
}
_onSendStatisticsChanged() {
if (this.settings.get_boolean('send-statistics')) {
this._monitorState();
} else {
this._unmonitorState();
}
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.battery':
this._receiveState(packet);
break;
case 'kdeconnect.battery.request':
this._sendState();
break;
}
}
connected() {
super.connected();
this._requestState();
this._sendState();
}
/**
* Notify that the remote device considers the battery level low
*/
_batteryNotification(event, title, body, iconName) {
let buttons = [];
// Offer the option to ring the device, if available
if (this.device.get_action_enabled('ring')) {
buttons = [{
label: _('Ring'),
action: 'ring',
parameter: null
}];
}
this.device.showNotification({
id: `battery|${event}`,
title: title,
body: body,
icon: new Gio.ThemedIcon({name: iconName}),
buttons: buttons
});
// Save the threshold level
this._thresholdLevel = this.level;
}
_lowBatteryNotification() {
if (!this.settings.get_boolean('low-battery-notification')) {
return;
}
this._batteryNotification(
'battery|low',
// TRANSLATORS: eg. Google Pixel: Battery is low
_('%s: Battery is low').format(this.device.name),
// TRANSLATORS: eg. 15% remaining
_('%d%% remaining').format(this.level),
'battery-caution-symbolic'
);
// Save the threshold level
this._thresholdLevel = this.level;
}
_fullBatteryNotification() {
if (!this.settings.get_boolean('full-battery-notification')) {
return;
}
this._batteryNotification(
'battery|full',
// TRANSLATORS: eg. Google Pixel: Battery is full
_('%s: Battery is full').format(this.device.name),
// TRANSLATORS: when the battery is fully charged
_('Fully Charged'),
'battery-full-charged-symbolic'
);
}
/**
* Handle a remote battery update.
*
* @param {kdeconnect.battery} packet - A kdeconnect.battery packet
*/
_receiveState(packet) {
// Charging state changed
this._charging = packet.body.isCharging;
// Level changed
if (this._level !== packet.body.currentCharge) {
this._level = packet.body.currentCharge;
// If the level is above the threshold hide the notification
if (this._level > this._thresholdLevel) {
this.device.hideNotification('battery|low');
}
// If the level just changed to full show a notification
if (this._level === 100) {
this._fullBatteryNotification();
// Otherwise hide it
} else {
this.device.hideNotification('battery|full');
}
}
// Device considers the level low
if (packet.body.thresholdEvent > 0) {
this._lowBatteryNotification();
}
this._updateEstimate();
this.__state.state = this.state;
}
/**
* Request the remote battery's current charge/state
*/
_requestState() {
this.device.sendPacket({
type: 'kdeconnect.battery.request',
body: {request: true}
});
}
/**
* Report the local battery's current charge/state
*/
_sendState() {
if (this._upowerId === 0) {
return;
}
let upower = this.service.components.get('upower');
if (upower) {
this.device.sendPacket({
type: 'kdeconnect.battery',
body: {
currentCharge: upower.level,
isCharging: upower.charging,
thresholdEvent: upower.threshold
}
});
}
}
/**
* UPower monitoring methods
*/
_monitorState() {
try {
let upower = this.service.components.get('upower');
let incoming = this.device.settings.get_strv('incoming-capabilities');
switch (true) {
case (!incoming.includes('kdeconnect.battery')):
case (this._upowerId > 0):
case (!upower):
case (!upower.is_present):
return;
}
this._upowerId = upower.connect(
'changed',
this._sendState.bind(this)
);
this._sendState();
} catch (e) {
logError(e, this.device.name);
this._unmonitorState();
}
}
_unmonitorState() {
try {
if (this._upowerId > 0) {
let upower = this.service.components.get('upower');
if (upower) {
upower.disconnect(this._upowerId);
}
this._upowerId = 0;
}
} catch (e) {
logError(e, this.device.name);
}
}
/**
* Recalculate the (dis)charge rate and update the estimated time remaining
* See also: https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/BatteryStats.java#1036
*/
_updateEstimate() {
let new_time = Math.floor(Date.now() / 1000);
let new_level = this.level;
// Read the state; rate has a default, time and level default to current
let [rate, time, level] = this.charging ? this._chargeState : this._dischargeState;
time = (Number.isFinite(time) && time > 0) ? time : new_time;
level = (Number.isFinite(level) && level > -1) ? level : new_level;
if (!Number.isFinite(rate) || rate < 1) {
rate = this.charging ? 54 : 864;
}
// Derive rate from time/level diffs (rate = seconds/percent)
let ldiff = this.charging ? new_level - level : level - new_level;
let tdiff = new_time - time;
let new_rate = tdiff / ldiff;
// Update the rate if it seems valid. Use a weighted average in favour
// of the new rate to account for possible missed level changes
if (new_rate && Number.isFinite(new_rate)) {
rate = Math.floor((rate * 0.4) + (new_rate * 0.6));
}
// Save the state
if (this.charging) {
this._chargeState = [rate, new_time, new_level];
} else {
this._dischargeState = [rate, new_time, new_level];
}
// Notify of the change
if (rate && this.charging) {
this._time = Math.floor(rate * (100 - new_level));
} else if (rate && !this.charging) {
this._time = Math.floor(rate * new_level);
}
}
/**
* Calculate and update the estimated time remaining, without affecting the
* (dis)charge rate.
*/
_estimateTime() {
// elision (rate, time, level)
let [rate,, level] = this.charging ? this._chargeState : this._dischargeState;
level = (level > -1) ? level : this.level;
if (!Number.isFinite(rate) || rate < 1) {
rate = this.charging ? 864 : 90;
}
if (rate && this.charging) {
this._time = Math.floor(rate * (100 - level));
} else if (rate && !this.charging) {
this._time = Math.floor(rate * level);
}
this.__state.state = this.state;
}
destroy() {
this.settings.disconnect(this._sendStatisticsId);
this._unmonitorState();
this.device.remove_action('battery');
super.destroy();
}
});

View File

@@ -0,0 +1,178 @@
'use strict';
const Gdk = imports.gi.Gdk;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Clipboard'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Clipboard',
incomingCapabilities: [
'kdeconnect.clipboard',
'kdeconnect.clipboard.connect'
],
outgoingCapabilities: [
'kdeconnect.clipboard',
'kdeconnect.clipboard.connect'
],
actions: {
clipboardPush: {
label: _('Clipboard Push'),
icon_name: 'edit-paste-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.clipboard']
},
clipboardPull: {
label: _('Clipboard Pull'),
icon_name: 'edit-copy-symbolic',
parameter_type: null,
incoming: ['kdeconnect.clipboard'],
outgoing: []
}
}
};
/**
* Clipboard Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/clipboard
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectClipboardPlugin',
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'clipboard');
try {
this._clipboard = this.service.components.get('clipboard');
// Watch local clipboard for changes
this._textChangedId = this._clipboard.connect(
'notify::text',
this._onLocalClipboardChanged.bind(this)
);
} catch (e) {
this.destroy();
throw e;
}
// Buffer content to allow selective sync
this._localBuffer = null;
this._localTimestamp = 0;
this._remoteBuffer = null;
}
connected() {
super.connected();
// TODO: if we're not auto-syncing local->remote, but we are doing the
// reverse, it's possible older remote content will end up
// overwriting newer local content.
if (!this.settings.get_boolean('send-content')) return;
if (this._localBuffer !== null && this._localTimestamp) {
this.device.sendPacket({
type: 'kdeconnect.clipboard.connect',
body: {
content: this._localBuffer,
timestamp: this._localTimestamp
}
});
}
}
handlePacket(packet) {
if (!packet.body.hasOwnProperty('content')) return;
if (packet.type === 'kdeconnect.clipboard') {
this._handleContent(packet);
} else if (packet.type === 'kdeconnect.clipboard.connect') {
this._handleConnectContent(packet);
}
}
_handleContent(packet) {
this._onRemoteClipboardChanged(packet.body.content);
}
_handleConnectContent(packet) {
if (packet.body.hasOwnProperty('timestamp') &&
packet.body.timestamp > this._localTimestamp) {
this._onRemoteClipboardChanged(packet.body.content);
}
}
/**
* Store the updated clipboard content and forward it if enabled
*/
_onLocalClipboardChanged(clipboard, pspec) {
this._localBuffer = clipboard.text;
this._localTimestamp = Date.now();
if (this.settings.get_boolean('send-content')) {
this.clipboardPush();
}
}
/**
* Store the updated clipboard content and apply it if enabled
*/
_onRemoteClipboardChanged(text) {
this._remoteBuffer = text;
if (this.settings.get_boolean('receive-content')) {
this.clipboardPull();
}
}
/**
* Copy to the remote clipboard; called by _onLocalClipboardChanged()
*/
clipboardPush() {
// Don't sync if the clipboard is empty or not text
if (!this._localTimestamp) return;
if (this._remoteBuffer !== this._localBuffer) {
this._remoteBuffer = this._localBuffer;
// If the buffer is %null, the clipboard contains non-text content,
// so we neither clear the remote clipboard nor pass the content
if (this._localBuffer !== null) {
this.device.sendPacket({
type: 'kdeconnect.clipboard',
body: {
content: this._localBuffer
}
});
}
}
}
/**
* Copy from the remote clipboard; called by _onRemoteClipboardChanged()
*/
clipboardPull() {
if (this._localBuffer !== this._remoteBuffer) {
this._localBuffer = this._remoteBuffer;
this._localTimestamp = Date.now();
this._clipboard.text = this._remoteBuffer;
}
}
destroy() {
if (this._clipboard && this._textChangedId) {
this._clipboard.disconnect(this._textChangedId);
}
super.destroy();
}
});

View File

@@ -0,0 +1,435 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
const Contacts = imports.service.components.contacts;
/*
* We prefer libebook's vCard parser if it's available
*/
var EBookContacts;
try {
EBookContacts = imports.gi.EBookContacts;
} catch (e) {
EBookContacts = null;
}
var Metadata = {
label: _('Contacts'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts',
incomingCapabilities: [
'kdeconnect.contacts.response_uids_timestamps',
'kdeconnect.contacts.response_vcards'
],
outgoingCapabilities: [
'kdeconnect.contacts.request_all_uids_timestamps',
'kdeconnect.contacts.request_vcards_by_uid'
],
actions: {}
};
/**
* vCard 2.1 Patterns
*/
const VCARD_FOLDING = /\r\n |\r |\n |=\n/g;
const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i;
const VCARD_BASIC = /^([^:;]+):(.+)$/;
const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/;
const VCARD_TYPED_KEY = /item\d{1,2}\./;
const VCARD_TYPED_META = /([a-z]+)=(.*)/i;
/**
* Contacts Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectContactsPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'contacts');
this._store = new Contacts.Store(device.id);
this._store.fetch = this.requestUids.bind(this);
// Notify when the store is ready
this._contactsStoreReadyId = this._store.connect(
'notify::context',
() => this.device.notify('contacts')
);
// Notify if the contacts source changes
this._contactsSourceChangedId = this.settings.connect(
'changed::contacts-source',
() => this.device.notify('contacts')
);
// Load the cache
this._store.load();
}
connected() {
super.connected();
this.requestUids();
}
clearCache() {
this._store.clear();
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.contacts.response_uids_timestamps') {
this._handleUids(packet);
} else if (packet.type === 'kdeconnect.contacts.response_vcards') {
this._handleVCards(packet);
}
}
_handleUids(packet) {
try {
let contacts = this._store.contacts;
let remote_uids = packet.body.uids;
let removed = false;
delete packet.body.uids;
// Usually a failed request, so avoid wiping the cache
if (remote_uids.length === 0) return;
// Delete any contacts that were removed on the device
for (let i = 0, len = contacts.length; i < len; i++) {
let contact = contacts[i];
if (!remote_uids.includes(contact.id)) {
this._store.remove(contact.id, false);
removed = true;
}
}
// Build a list of new or updated contacts
let uids = [];
for (let [uid, timestamp] of Object.entries(packet.body)) {
let contact = this._store.get_contact(uid);
if (!contact || contact.timestamp !== timestamp) {
uids.push(uid);
}
}
// Send a request for any new or updated contacts
if (uids.length) {
this.requestVCards(uids);
}
// If we removed any contacts, save the cache
if (removed) {
this._store.save();
}
} catch (e) {
logError(e);
}
}
/**
* Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string
*
* See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js
*
* @param {string} input - The QUOTED-PRINTABLE string
* @return {string} - The decoded string
*/
decode_quoted_printable(input) {
return input
// https://tools.ietf.org/html/rfc2045#section-6.7, rule 3
.replace(/[\t\x20]$/gm, '')
// Remove hard line breaks preceded by `=`
.replace(/=(?:\r\n?|\n|$)/g, '')
// https://tools.ietf.org/html/rfc2045#section-6.7, note 1.
.replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => {
let codePoint = parseInt($1, 16);
return String.fromCharCode(codePoint);
});
}
/**
* Decode a string encoded as "UTF-8" and return a regular string
*
* See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js
*
* @param {string} input - The UTF-8 string
* @return {string} - The decoded string
*/
decode_utf8(input) {
try {
let output = [];
let i = 0;
let c1 = 0;
let seqlen = 0;
while (i < input.length) {
c1 = input.charCodeAt(i) & 0xFF;
seqlen = 0;
if (c1 <= 0xBF) {
c1 = (c1 & 0x7F);
seqlen = 1;
} else if (c1 <= 0xDF) {
c1 = (c1 & 0x1F);
seqlen = 2;
} else if (c1 <= 0xEF) {
c1 = (c1 & 0x0F);
seqlen = 3;
} else {
c1 = (c1 & 0x07);
seqlen = 4;
}
for (let ai = 1; ai < seqlen; ++ai) {
c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F));
}
if (seqlen === 4) {
c1 -= 0x10000;
output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)));
output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)));
} else {
output.push(String.fromCharCode(c1));
}
i += seqlen;
}
return output.join('');
// Fallback to old unfaithful
} catch (e) {
try {
return decodeURIComponent(escape(input));
// Say "chowdah" frenchie!
} catch (e) {
debug(e, `Failed to decode UTF-8 VCard field ${input}`);
return input;
}
}
}
/**
* Parse a VCard v2.1 and return a dictionary of data
*
* See: http://jsfiddle.net/ARTsinn/P2t2P/
*
* @param {string} vcard_data - The raw VCard data
*/
parseVCard21(vcard_data) {
// vcard skeleton
let vcard = {
fn: _('Unknown Contact'),
tel: []
};
// Remove line folding and split
let lines = vcard_data.replace(VCARD_FOLDING, '').split(/\r\n|\r|\n/);
for (let i = 0, len = lines.length; i < len; i++) {
let line = lines[i];
let results, key, type, value;
// Empty line or a property we aren't interested in
if (!line || !line.match(VCARD_SUPPORTED)) continue;
// Basic Fields (fn, x-kdeconnect-timestamp, etc)
if ((results = line.match(VCARD_BASIC))) {
[results, key, value] = results;
vcard[key.toLowerCase()] = value;
continue;
}
// Typed Fields (tel, adr, etc)
if ((results = line.match(VCARD_TYPED))) {
[results, key, type, value] = results;
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
value = value.split(';');
type = type.split(';');
// Type(s)
let meta = {};
for (let i = 0, len = type.length; i < len; i++) {
let res = type[i].match(VCARD_TYPED_META);
if (res) {
meta[res[1]] = res[2];
} else {
meta['type' + (i === 0 ? '' : i)] = type[i].toLowerCase();
}
}
// Value(s)
if (vcard[key] === undefined) vcard[key] = [];
// Decode QUOTABLE-PRINTABLE
if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') {
delete meta.ENCODING;
value = value.map(v => this.decode_quoted_printable(v));
}
// Decode UTF-8
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
delete meta.CHARSET;
value = value.map(v => this.decode_utf8(v));
}
// Special case for FN (full name)
if (key === 'fn') {
vcard[key] = value[0];
} else {
vcard[key].push({meta: meta, value: value});
}
}
}
return vcard;
}
async parseVCardNative(uid, vcard_data) {
try {
let vcard = this.parseVCard21(vcard_data);
let contact = {
id: uid,
name: vcard.fn,
numbers: [],
origin: 'device',
timestamp: parseInt(vcard['x-kdeconnect-timestamp'])
};
// Phone Numbers
contact.numbers = vcard.tel.map(entry => {
let type = 'unknown';
if (entry.meta && entry.meta.type) {
type = entry.meta.type;
}
return {type: type, value: entry.value[0]};
});
// Avatar
if (vcard.photo) {
let data = GLib.base64_decode(vcard.photo[0].value[0]);
contact.avatar = await this._store.storeAvatar(data);
}
return contact;
} catch (e) {
debug(e, `Failed to parse VCard contact ${uid}`);
return undefined;
}
}
async parseVCard(uid, vcard_data) {
try {
let contact = {
id: uid,
name: _('Unknown Contact'),
numbers: [],
origin: 'device',
timestamp: 0
};
let evcard = EBookContacts.VCard.new_from_string(vcard_data);
let evattrs = evcard.get_attributes();
for (let i = 0, len = evattrs.length; i < len; i++) {
let attr = evattrs[i];
let data, number;
switch (attr.get_name().toLowerCase()) {
case 'fn':
contact.name = attr.get_value();
break;
case 'tel':
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);
break;
case 'x-kdeconnect-timestamp':
contact.timestamp = parseInt(attr.get_value());
break;
case 'photo':
data = GLib.base64_decode(attr.get_value());
contact.avatar = await this._store.storeAvatar(data);
break;
}
}
return contact;
} catch (e) {
debug(e, `Failed to parse VCard contact ${uid}`);
return undefined;
}
}
async _handleVCards(packet) {
try {
// We don't use this
delete packet.body.uids;
// Parse each vCard and add the contact
for (let [uid, vcard] of Object.entries(packet.body)) {
let contact;
if (EBookContacts) {
contact = await this.parseVCard(uid, vcard);
} else {
contact = await this.parseVCardNative(uid, vcard);
}
if (contact) {
this._store.add(contact);
}
}
} catch (e) {
logError(e);
}
}
requestUids() {
this.device.sendPacket({
type: 'kdeconnect.contacts.request_all_uids_timestamps'
});
}
requestVCards(uids) {
this.device.sendPacket({
type: 'kdeconnect.contacts.request_vcards_by_uid',
body: {
uids: uids
}
});
}
destroy() {
this.settings.disconnect(this._contactsStoreReadyId);
this.settings.disconnect(this._contactsSourceChangedId);
super.destroy();
}
});

View File

@@ -0,0 +1,193 @@
'use strict';
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Find My Phone'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.FindMyPhone',
incomingCapabilities: ['kdeconnect.findmyphone.request'],
outgoingCapabilities: ['kdeconnect.findmyphone.request'],
actions: {
ring: {
label: _('Ring'),
icon_name: 'phonelink-ring-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.findmyphone.request']
}
}
};
/**
* FindMyPhone Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/findmyphone
*
* TODO: cancel incoming requests on disconnect?
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectFindMyPhonePlugin',
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'findmyphone');
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.findmyphone.request') {
this._handleRequest();
}
}
/**
* Handle an incoming location request.
*/
_handleRequest() {
try {
// If this is a second request, stop announcing and return
if (this._dialog) {
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
return;
}
this._dialog = new Dialog(this.device.name);
this._dialog.connect('response', () => this._dialog = null);
} catch (e) {
this._cancelRequest();
logError(e, this.device.name);
}
}
_cancelRequest() {
if (this._dialog) {
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
}
}
/**
* Request the remote device announce it's location
*/
ring() {
this.device.sendPacket({
type: 'kdeconnect.findmyphone.request',
body: {}
});
}
destroy() {
this._cancelRequest();
super.destroy();
}
});
/**
* Used to ensure 'audible-bell' is enabled for fallback
*/
const WM_SETTINGS = new Gio.Settings({
schema_id: 'org.gnome.desktop.wm.preferences',
path: '/org/gnome/desktop/wm/preferences/'
});
/**
* A custom GtkMessageDialog for alerting of incoming requests
*/
const Dialog = GObject.registerClass({
GTypeName: 'GSConnectFindMyPhoneDialog'
}, class Dialog extends Gtk.MessageDialog {
_init(name) {
super._init({
buttons: Gtk.ButtonsType.CLOSE,
image: new Gtk.Image({
icon_name: 'phonelink-ring-symbolic',
pixel_size: 512,
halign: Gtk.Align.CENTER,
hexpand: true,
valign: Gtk.Align.CENTER,
vexpand: true,
visible: true
}),
urgency_hint: true
});
this.set_keep_above(true);
this.maximize();
this.message_area.destroy();
// If the mixer is available start fading the volume up
let service = Gio.Application.get_default();
let mixer = service.components.get('pulseaudio');
if (mixer) {
this._stream = mixer.output;
this._previousMuted = this._stream.muted;
this._previousVolume = this._stream.volume;
this._stream.muted = false;
this._stream.fade(0.85, 15);
// Otherwise ensure audible-bell is enabled
} else {
this._previousBell = WM_SETTINGS.get_boolean('audible-bell');
WM_SETTINGS.set_boolean('audible-bell', true);
}
// Start the alarm
let sound = service.components.get('sound');
if (sound !== undefined) {
sound.loopSound('phone-incoming-call', this.cancellable);
}
// Show the dialog
this.show_all();
}
vfunc_key_press_event(event) {
this.response(Gtk.ResponseType.DELETE_EVENT);
return Gdk.EVENT_STOP;
}
vfunc_motion_notify_event(event) {
this.response(Gtk.ResponseType.DELETE_EVENT);
return Gdk.EVENT_STOP;
}
vfunc_response(response_id) {
// Stop the alarm
this.cancellable.cancel();
// Restore the mixer level
if (this._stream) {
this._stream.muted = this._previousMuted;
this._stream.fade(this._previousVolume);
// Restore the audible-bell
} else {
WM_SETTINGS.set_boolean('audible-bell', this._previousBell);
}
this.destroy();
}
get cancellable() {
if (this._cancellable === undefined) {
this._cancellable = new Gio.Cancellable();
}
return this._cancellable;
}
});

View File

@@ -0,0 +1,631 @@
'use strict';
const Atspi = imports.gi.Atspi;
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Mousepad'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Mousepad',
incomingCapabilities: [
'kdeconnect.mousepad.echo',
'kdeconnect.mousepad.request',
'kdeconnect.mousepad.keyboardstate'
],
outgoingCapabilities: [
'kdeconnect.mousepad.echo',
'kdeconnect.mousepad.request',
'kdeconnect.mousepad.keyboardstate'
],
actions: {
keyboard: {
label: _('Keyboard'),
icon_name: 'input-keyboard-symbolic',
parameter_type: null,
incoming: ['kdeconnect.mousepad.echo', 'kdeconnect.mousepad.keyboardstate'],
outgoing: ['kdeconnect.mousepad.request']
}
}
};
/**
* A map of "KDE Connect" keyvals to Gdk
*/
const KeyMap = new Map([
[1, Gdk.KEY_BackSpace],
[2, Gdk.KEY_Tab],
[3, Gdk.KEY_Linefeed],
[4, Gdk.KEY_Left],
[5, Gdk.KEY_Up],
[6, Gdk.KEY_Right],
[7, Gdk.KEY_Down],
[8, Gdk.KEY_Page_Up],
[9, Gdk.KEY_Page_Down],
[10, Gdk.KEY_Home],
[11, Gdk.KEY_End],
[12, Gdk.KEY_Return],
[13, Gdk.KEY_Delete],
[14, Gdk.KEY_Escape],
[15, Gdk.KEY_Sys_Req],
[16, Gdk.KEY_Scroll_Lock],
[17, 0],
[18, 0],
[19, 0],
[20, 0],
[21, Gdk.KEY_F1],
[22, Gdk.KEY_F2],
[23, Gdk.KEY_F3],
[24, Gdk.KEY_F4],
[25, Gdk.KEY_F5],
[26, Gdk.KEY_F6],
[27, Gdk.KEY_F7],
[28, Gdk.KEY_F8],
[29, Gdk.KEY_F9],
[30, Gdk.KEY_F10],
[31, Gdk.KEY_F11],
[32, Gdk.KEY_F12]
]);
/**
* Mousepad Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mousepad
*
* TODO: support outgoing mouse events?
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectMousepadPlugin',
Properties: {
'state': GObject.ParamSpec.boolean(
'state',
'State',
'Remote keyboard state',
GObject.ParamFlags.READABLE,
false
),
'share-control': GObject.ParamSpec.boolean(
'share-control',
'Share Control',
'Share control of mouse & keyboard',
GObject.ParamFlags.READWRITE,
false
)
}
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'mousepad');
this._input = this.service.components.get('input');
this.settings.bind(
'share-control',
this,
'share-control',
Gio.SettingsBindFlags.GET
);
this._stateId = 0;
}
connected() {
super.connected();
this.sendState();
}
disconnected() {
super.disconnected();
// Set the keyboard state to inactive
this._state = false;
this._stateId = 0;
this.notify('state');
}
get state() {
if (this._state === undefined) {
this._state = false;
}
return this._state;
}
get virtual_keyboard() {
return this._virtual_keyboard;
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.mousepad.request':
if (this.share_control) {
this._handleInput(packet.body);
}
break;
case 'kdeconnect.mousepad.echo':
this._handleEcho(packet.body);
break;
case 'kdeconnect.mousepad.keyboardstate':
this._handleState(packet);
break;
}
}
_handleInput(input) {
let keysym;
let modifiers = 0;
// These are ordered, as much as possible, to create the shortest code
// path for high-frequency, low-latency events (eg. mouse movement)
switch (true) {
case input.hasOwnProperty('scroll'):
this._input.scrollPointer(input.dx, input.dy);
break;
case (input.hasOwnProperty('dx') && input.hasOwnProperty('dy')):
this._input.movePointer(input.dx, input.dy);
break;
case (input.hasOwnProperty('key') || input.hasOwnProperty('specialKey')):
// NOTE: \u0000 sometimes sent in advance of a specialKey packet
if (input.key && input.key === '\u0000') return;
// Modifiers
if (input.alt || input.ctrl || input.shift || input.super) {
if (input.alt) modifiers |= Gdk.ModifierType.MOD1_MASK;
if (input.ctrl) modifiers |= Gdk.ModifierType.CONTROL_MASK;
if (input.shift) modifiers |= Gdk.ModifierType.SHIFT_MASK;
if (input.super) modifiers |= Gdk.ModifierType.SUPER_MASK;
}
// Regular key (printable ASCII or Unicode)
if (input.key) {
this._input.pressKey(input.key, modifiers);
this.sendEcho(input);
// Special key (eg. non-printable ASCII)
} else if (input.specialKey && KeyMap.has(input.specialKey)) {
keysym = KeyMap.get(input.specialKey);
this._input.pressKey(keysym, modifiers);
this.sendEcho(input);
}
break;
case input.hasOwnProperty('singleclick'):
this._input.clickPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('doubleclick'):
this._input.doubleclickPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('middleclick'):
this._input.clickPointer(Gdk.BUTTON_MIDDLE);
break;
case input.hasOwnProperty('rightclick'):
this._input.clickPointer(Gdk.BUTTON_SECONDARY);
break;
case input.hasOwnProperty('singlehold'):
this._input.pressPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('singlerelease'):
this._input.releasePointer(Gdk.BUTTON_PRIMARY);
break;
default:
logError(new Error('Unknown input'));
}
}
/**
* Send an echo/ACK of @input, if requested
*
* @param {object} input - 'body' of a 'kdeconnect.mousepad.request' packet
*/
sendEcho(input) {
if (input.sendAck) {
delete input.sendAck;
input.isAck = true;
this.device.sendPacket({
type: 'kdeconnect.mousepad.echo',
body: input
});
}
}
_handleEcho(input) {
if (!this._dialog || !this._dialog.visible) {
return;
}
if (input.alt || input.ctrl || input.super) {
return;
}
if (input.key) {
this._dialog.text.buffer.text += input.key;
} else if (KeyMap.get(input.specialKey) === Gdk.KEY_BackSpace) {
this._dialog.text.emit('backspace');
}
}
_handleState(packet) {
// FIXME: ensure we don't get packets out of order
if (packet.id > this._stateId) {
this._state = packet.body.state;
this._stateId = packet.id;
this.notify('state');
}
}
/**
* Send the local keyboard state
*
* @param {boolean} state - Whether we're ready to accept input
*/
sendState() {
this.device.sendPacket({
type: 'kdeconnect.mousepad.keyboardstate',
body: {
state: this.share_control
}
});
}
/**
* Open the Keyboard Input dialog
*/
keyboard() {
if (!this._dialog) {
this._dialog = new KeyboardInputDialog({
device: this.device,
plugin: this
});
}
this._dialog.present();
}
});
/**
* A map of Gdk to "KDE Connect" keyvals
*/
const ReverseKeyMap = new Map([
[Gdk.KEY_BackSpace, 1],
[Gdk.KEY_Tab, 2],
[Gdk.KEY_Linefeed, 3],
[Gdk.KEY_Left, 4],
[Gdk.KEY_Up, 5],
[Gdk.KEY_Right, 6],
[Gdk.KEY_Down, 7],
[Gdk.KEY_Page_Up, 8],
[Gdk.KEY_Page_Down, 9],
[Gdk.KEY_Home, 10],
[Gdk.KEY_End, 11],
[Gdk.KEY_Return, 12],
[Gdk.KEY_Delete, 13],
[Gdk.KEY_Escape, 14],
[Gdk.KEY_Sys_Req, 15],
[Gdk.KEY_Scroll_Lock, 16],
[Gdk.KEY_F1, 21],
[Gdk.KEY_F2, 22],
[Gdk.KEY_F3, 23],
[Gdk.KEY_F4, 24],
[Gdk.KEY_F5, 25],
[Gdk.KEY_F6, 26],
[Gdk.KEY_F7, 27],
[Gdk.KEY_F8, 28],
[Gdk.KEY_F9, 29],
[Gdk.KEY_F10, 30],
[Gdk.KEY_F11, 31],
[Gdk.KEY_F12, 32]
]);
/**
* A list of keyvals we consider modifiers
*/
const MOD_KEYS = [
Gdk.KEY_Alt_L,
Gdk.KEY_Alt_R,
Gdk.KEY_Caps_Lock,
Gdk.KEY_Control_L,
Gdk.KEY_Control_R,
Gdk.KEY_Meta_L,
Gdk.KEY_Meta_R,
Gdk.KEY_Num_Lock,
Gdk.KEY_Shift_L,
Gdk.KEY_Shift_R,
Gdk.KEY_Super_L,
Gdk.KEY_Super_R
];
/**
* Some convenience functions for checking keyvals for modifiers
*/
const isAlt = (key) => [Gdk.KEY_Alt_L, Gdk.KEY_Alt_R].includes(key);
const isCtrl = (key) => [Gdk.KEY_Control_L, Gdk.KEY_Control_R].includes(key);
const isShift = (key) => [Gdk.KEY_Shift_L, Gdk.KEY_Shift_R].includes(key);
const isSuper = (key) => [Gdk.KEY_Super_L, Gdk.KEY_Super_R].includes(key);
var KeyboardInputDialog = GObject.registerClass({
GTypeName: 'GSConnectMousepadKeyboardInputDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The mousepad plugin associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
)
}
}, class KeyboardInputDialog extends Gtk.Dialog {
_init(params) {
super._init(Object.assign({
use_header_bar: true,
default_width: 480,
window_position: Gtk.WindowPosition.CENTER
}, params));
let headerbar = this.get_titlebar();
headerbar.title = _('Keyboard');
headerbar.subtitle = this.device.name;
// Main Box
let content = this.get_content_area();
content.border_width = 0;
// Infobar
this.infobar = new Gtk.Revealer();
content.add(this.infobar);
let bar = new Gtk.InfoBar({message_type: Gtk.MessageType.WARNING});
this.infobar.add(bar);
let infoicon = new Gtk.Image({icon_name: 'dialog-warning-symbolic'});
bar.get_content_area().add(infoicon);
let infolabel = new Gtk.Label({
// TRANSLATORS: Displayed when the remote keyboard is not ready to accept input
label: _('Remote keyboard on %s is not active').format(this.device.name)
});
bar.get_content_area().add(infolabel);
let infolink = new Gtk.LinkButton({
label: _('Help'),
uri: 'https://github.com/andyholmes/gnome-shell-extension-gsconnect/wiki/Help#remote-keyboard-not-active'
});
bar.get_action_area().add(infolink);
// Content
let layout = new Gtk.Grid({
column_spacing: 6,
margin: 6
});
content.add(layout);
// Modifier Buttons
this.shift_label = new Gtk.ShortcutLabel({
accelerator: Gtk.accelerator_name(0, Gdk.ModifierType.SHIFT_MASK),
halign: Gtk.Align.END,
valign: Gtk.Align.START,
sensitive: false
});
layout.attach(this.shift_label, 0, 0, 1, 1);
this.ctrl_label = new Gtk.ShortcutLabel({
accelerator: Gtk.accelerator_name(0, Gdk.ModifierType.CONTROL_MASK),
halign: Gtk.Align.END,
valign: Gtk.Align.START,
sensitive: false
});
layout.attach(this.ctrl_label, 0, 1, 1, 1);
this.alt_label = new Gtk.ShortcutLabel({
accelerator: Gtk.accelerator_name(0, Gdk.ModifierType.MOD1_MASK),
halign: Gtk.Align.END,
valign: Gtk.Align.START,
sensitive: false
});
layout.attach(this.alt_label, 0, 2, 1, 1);
this.super_label = new Gtk.ShortcutLabel({
accelerator: Gtk.accelerator_name(0, Gdk.ModifierType.SUPER_MASK),
halign: Gtk.Align.END,
valign: Gtk.Align.START,
sensitive: false
});
layout.attach(this.super_label, 0, 3, 1, 1);
// Text Input
let scroll = new Gtk.ScrolledWindow({
hscrollbar_policy: Gtk.PolicyType.NEVER,
shadow_type: Gtk.ShadowType.IN
});
layout.attach(scroll, 1, 0, 1, 4);
this.text = new Gtk.TextView({
border_width: 6,
hexpand: true,
vexpand: true,
visible: true
});
scroll.add(this.text);
this.infobar.connect('notify::reveal-child', this._onState.bind(this));
this.plugin.bind_property('state', this.infobar, 'reveal-child', 6);
this.show_all();
}
vfunc_delete_event(event) {
this._ungrab();
return this.hide_on_delete();
}
vfunc_key_release_event(event) {
if (!this.plugin.state) {
return true;
}
let keyvalLower = Gdk.keyval_to_lower(event.keyval);
let realMask = event.state & Gtk.accelerator_get_default_mod_mask();
this.alt_label.sensitive = !isAlt(keyvalLower) && (realMask & Gdk.ModifierType.MOD1_MASK);
this.ctrl_label.sensitive = !isCtrl(keyvalLower) && (realMask & Gdk.ModifierType.CONTROL_MASK);
this.shift_label.sensitive = !isShift(keyvalLower) && (realMask & Gdk.ModifierType.SHIFT_MASK);
this.super_label.sensitive = !isSuper(keyvalLower) && (realMask & Gdk.ModifierType.SUPER_MASK);
return super.vfunc_key_release_event(event);
}
vfunc_key_press_event(event) {
if (!this.plugin.state) {
return true;
}
let keyvalLower = Gdk.keyval_to_lower(event.keyval);
let realMask = event.state & Gtk.accelerator_get_default_mod_mask();
this.alt_label.sensitive = isAlt(keyvalLower) || (realMask & Gdk.ModifierType.MOD1_MASK);
this.ctrl_label.sensitive = isCtrl(keyvalLower) || (realMask & Gdk.ModifierType.CONTROL_MASK);
this.shift_label.sensitive = isShift(keyvalLower) || (realMask & Gdk.ModifierType.SHIFT_MASK);
this.super_label.sensitive = isSuper(keyvalLower) || (realMask & Gdk.ModifierType.SUPER_MASK);
// Wait for a real key before sending
if (MOD_KEYS.includes(keyvalLower)) {
return false;
}
// Normalize Tab
if (keyvalLower === Gdk.KEY_ISO_Left_Tab) {
keyvalLower = Gdk.KEY_Tab;
}
// Put shift back if it changed the case of the key, not otherwise.
if (keyvalLower !== event.keyval) {
realMask |= Gdk.ModifierType.SHIFT_MASK;
}
// HACK: we don't want to use SysRq as a keybinding (but we do want
// Alt+Print), so we avoid translation from Alt+Print to SysRq
if (keyvalLower === Gdk.KEY_Sys_Req && (realMask & Gdk.ModifierType.MOD1_MASK) !== 0) {
keyvalLower = Gdk.KEY_Print;
}
// CapsLock isn't supported as a keybinding modifier, so keep it from
// confusing us
realMask &= ~Gdk.ModifierType.LOCK_MASK;
if (keyvalLower !== 0) {
debug(`keyval: ${event.keyval}, mask: ${realMask}`);
let request = {
alt: !!(realMask & Gdk.ModifierType.MOD1_MASK),
ctrl: !!(realMask & Gdk.ModifierType.CONTROL_MASK),
shift: !!(realMask & Gdk.ModifierType.SHIFT_MASK),
super: !!(realMask & Gdk.ModifierType.SUPER_MASK),
sendAck: true
};
// specialKey
if (ReverseKeyMap.has(event.keyval)) {
request.specialKey = ReverseKeyMap.get(event.keyval);
// key
} else {
let codePoint = Gdk.keyval_to_unicode(event.keyval);
request.key = String.fromCodePoint(codePoint);
}
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: request
});
// Pass these key combinations rather than using the echo reply
if (request.alt || request.ctrl || request.super) {
return super.vfunc_key_press_event(event);
}
}
return false;
}
vfunc_window_state_event(event) {
if (this.plugin.state && !!(event.new_window_state & Gdk.WindowState.FOCUSED)) {
this._grab();
} else {
this._ungrab();
}
return super.vfunc_window_state_event(event);
}
_onState(widget) {
if (this.plugin.state && this.is_active) {
this._grab();
} else {
this._ungrab();
}
}
_grab() {
if (!this.visible || this._device) return;
let seat = Gdk.Display.get_default().get_default_seat();
let status = seat.grab(
this.get_window(),
Gdk.SeatCapabilities.KEYBOARD,
false,
null,
null,
null
);
if (status !== Gdk.GrabStatus.SUCCESS) {
logError(new Error('Grabbing keyboard failed'));
return;
}
this._device = seat.get_keyboard();
this.grab_add();
this.text.has_focus = true;
}
_ungrab() {
if (this._device) {
this._device.get_seat().ungrab();
this._device = null;
this.grab_remove();
}
this.text.buffer.text = '';
}
});

View File

@@ -0,0 +1,977 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
const DBus = imports.utils.dbus;
var Metadata = {
label: _('MPRIS'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS',
incomingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
outgoingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
actions: {}
};
/**
* MPRIS Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mpriscontrol
*
* See also:
* https://specifications.freedesktop.org/mpris-spec/latest/
* https://github.com/GNOME/gnome-shell/blob/master/js/ui/mpris.js
* https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer/wiki/Known-Player-Bugs
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlugin',
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'mpris');
try {
this._mpris = this.service.components.get('mpris');
this._notifyPlayersId = this._mpris.connect(
'notify::players',
this._sendPlayerList.bind(this)
);
this._playerChangedId = this._mpris.connect(
'player-changed',
this._onPlayerChanged.bind(this)
);
this._playerSeekedId = this._mpris.connect(
'player-seeked',
this._onPlayerSeeked.bind(this)
);
this._players = new Map();
this._transferring = new WeakSet();
this._updating = new WeakSet();
} catch (e) {
this.destroy();
throw e;
}
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.mpris.request') {
this._handleRequest(packet);
} else if (packet.type === 'kdeconnect.mpris') {
this._handleStatus(packet);
}
}
connected() {
super.connected();
this._requestPlayerList();
this._sendPlayerList();
}
disconnected() {
super.disconnected();
for (let [identity, player] of this._players.entries()) {
player.destroy();
this._players.delete(identity);
}
}
_handleStatus(packet) {
try {
if (packet.body.hasOwnProperty('playerList')) {
this._handlePlayerList(packet.body.playerList);
} else if (packet.body.hasOwnProperty('player')) {
this._handlePlayerState(packet.body);
}
} catch (e) {
debug(e, `${this.device.name}: MPRIS`);
}
}
/**
* Handle a player list update
*
* @param {array} playerList - A list of remote player names
*/
_handlePlayerList(playerList) {
for (let player of this._players.values()) {
if (!playerList.includes(player.Identity)) {
this._players.delete(player.Identity);
player.destroy();
}
}
for (let identity of playerList) {
this._device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: identity,
requestNowPlaying: true,
requestVolume: true
}
});
}
}
/**
* Handle a player state update
*
* @param {object} state - The body of a kdeconnect.mpris packet
*/
_handlePlayerState(state) {
let player = this._players.get(state.player);
if (player === undefined) {
player = new RemotePlayer(this.device, state);
this._players.set(state.player, player);
} else {
player.parseState(state);
}
}
/**
* Request the list of player identities
*/
_requestPlayerList() {
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
requestPlayerList: true
}
});
}
_handleRequest(packet) {
// A request for the list of players
if (packet.body.requestPlayerList) {
this._sendPlayerList();
// A request for an unknown player; send the list of players
} else if (!this._mpris.identities.includes(packet.body.player)) {
this._sendPlayerList();
// An album art request
} else if (packet.body.hasOwnProperty('albumArtUrl')) {
this._sendAlbumArt(packet);
// A player command
} else {
this._handleCommand(packet);
}
}
/**
* Handle an incoming player command or information request
*
* @param {kdeconnect.mpris.request} - A command for a specific player
*/
async _handleCommand(packet) {
if (!this.settings.get_boolean('share-players')) {
return;
}
let player;
try {
player = this._mpris.getPlayer(packet.body.player);
if (player === undefined || this._updating.has(player))
return;
// Player Actions
if (packet.body.hasOwnProperty('action')) {
switch (packet.body.action) {
case 'PlayPause':
case 'Play':
case 'Pause':
case 'Next':
case 'Previous':
case 'Stop':
player[packet.body.action]();
break;
default:
logError(new Error(`unknown action: ${packet.body.action}`));
}
}
// Player Properties
if (packet.body.hasOwnProperty('setVolume')) {
player.Volume = packet.body.setVolume / 100;
}
if (packet.body.hasOwnProperty('Seek')) {
await player.Seek(packet.body.Seek);
}
if (packet.body.hasOwnProperty('SetPosition')) {
let offset = (packet.body.SetPosition * 1000) - player.Position;
await player.Seek(offset);
}
// Information Request
let hasResponse = false;
let response = {
type: 'kdeconnect.mpris',
body: {
player: packet.body.player
}
};
if (packet.body.hasOwnProperty('requestNowPlaying')) {
hasResponse = true;
Object.assign(response.body, {
pos: Math.floor(player.Position / 1000),
isPlaying: (player.PlaybackStatus === 'Playing'),
canPause: player.CanPause,
canPlay: player.CanPlay,
canGoNext: player.CanGoNext,
canGoPrevious: player.CanGoPrevious,
canSeek: player.CanSeek,
artist: _('Unknown'),
title: _('Unknown')
});
let metadata = player.Metadata;
if (metadata.hasOwnProperty('mpris:artUrl')) {
let file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
response.body.albumArtUrl = file.get_uri();
}
if (metadata.hasOwnProperty('mpris:length')) {
let trackLen = Math.floor(metadata['mpris:length'] / 1000);
response.body.length = trackLen;
}
if (metadata.hasOwnProperty('xesam:artist')) {
let artists = metadata['xesam:artist'];
response.body.artist = artists.join(', ');
}
if (metadata.hasOwnProperty('xesam:title')) {
response.body.title = metadata['xesam:title'];
}
if (metadata.hasOwnProperty('xesam:album')) {
response.body.album = metadata['xesam:album'];
}
response.body.nowPlaying = [
response.body.artist,
response.body.title
].join(' - ');
}
if (packet.body.hasOwnProperty('requestVolume')) {
hasResponse = true;
response.body.volume = player.Volume * 100;
}
if (hasResponse) {
this.device.sendPacket(response);
}
} catch (e) {
logError(e);
} finally {
this._updating.delete(player);
}
}
_onPlayerChanged(mpris, player) {
if (!this.settings.get_boolean('share-players')) {
return;
}
this._handleCommand({
body: {
player: player.Identity,
requestNowPlaying: true,
requestVolume: true
}
});
}
_onPlayerSeeked(mpris, player) {
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
player: player.Identity,
pos: Math.floor(player.Position / 1000)
}
});
}
async _sendAlbumArt(packet) {
let player;
try {
// Reject concurrent requests for album art
player = this._mpris.getPlayer(packet.body.player);
if (player === undefined || this._transferring.has(player)) {
return;
}
// Ensure the requested albumArtUrl matches the current mpris:artUrl
let metadata = player.Metadata;
if (!metadata.hasOwnProperty('mpris:artUrl')) {
return;
}
let file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
let request = Gio.File.new_for_uri(packet.body.albumArtUrl);
if (file.get_uri() !== request.get_uri()) {
throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);
}
// Start the transfer process
this._transferring.add(player);
let transfer = this.device.createTransfer({
input_stream: file.read(null),
size: file.query_info('standard::size', 0, null).get_size()
});
await transfer.upload({
type: 'kdeconnect.mpris',
body: {
transferringAlbumArt: true,
player: packet.body.player,
albumArtUrl: packet.body.albumArtUrl
}
});
} catch (e) {
debug(e, 'transferring album art');
} finally {
this._transferring.delete(player);
}
}
/**
* Send the list of player identities and indicate whether we support
* transferring album art
*/
_sendPlayerList() {
let playerList = [];
if (this.settings.get_boolean('share-players')) {
playerList = this._mpris.identities;
}
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
playerList: playerList,
supportAlbumArtPayload: true
}
});
}
destroy() {
try {
this._mpris.disconnect(this._notifyPlayersId);
this._mpris.disconnect(this._playerChangedId);
this._mpris.disconnect(this._playerSeekedId);
} catch (e) {
// Silence errors
}
for (let [identity, player] of this._players.entries()) {
player.destroy();
this._players.delete(identity);
}
super.destroy();
}
});
/*
* A class for mirroring a remote Media Player on DBus
*/
const MPRISIface = gsconnect.dbusinfo.lookup_interface('org.mpris.MediaPlayer2');
const MPRISPlayerIface = gsconnect.dbusinfo.lookup_interface('org.mpris.MediaPlayer2.Player');
var RemotePlayer = GObject.registerClass({
GTypeName: 'GSConnectMPRISRemotePlayer',
Properties: {
'PlaybackStatus': GObject.ParamSpec.string(
'PlaybackStatus',
'Playback Status',
'The current playback status.',
GObject.ParamFlags.READABLE,
null
),
'LoopStatus': GObject.ParamSpec.string(
'LoopStatus',
'Loop Status',
'The current loop status.',
GObject.ParamFlags.READWRITE,
null
),
'Rate': GObject.ParamSpec.double(
'Rate',
'Rate',
'The current playback rate.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'Shuffle': GObject.ParamSpec.boolean(
'Shuffle',
'Shuffle',
'Whether track changes are linear.',
GObject.ParamFlags.READWRITE,
null
),
'Metadata': GObject.param_spec_variant(
'Metadata',
'Metadata',
'The metadata of the current element.',
new GLib.VariantType('a{sv}'),
null,
GObject.ParamFlags.READABLE
),
'Volume': GObject.ParamSpec.double(
'Volume',
'Volume',
'The volume level.',
GObject.ParamFlags.READWRITE,
0.0, 1.0,
1.0
),
'Position': GObject.ParamSpec.int64(
'Position',
'Position',
'The current track position in microseconds.',
GObject.ParamFlags.READABLE,
0, Number.MAX_SAFE_INTEGER,
0
),
'CanGoNext': GObject.ParamSpec.boolean(
'CanGoNext',
'Can Go Next',
'Whether the client can call the Next method.',
GObject.ParamFlags.READABLE,
false
),
'CanGoPrevious': GObject.ParamSpec.boolean(
'CanGoPrevious',
'Can Go Previous',
'Whether the client can call the Previous method.',
GObject.ParamFlags.READABLE,
false
),
'CanPlay': GObject.ParamSpec.boolean(
'CanPlay',
'Can Play',
'Whether playback can be started using Play or PlayPause.',
GObject.ParamFlags.READABLE,
false
),
'CanPause': GObject.ParamSpec.boolean(
'CanPause',
'Can Pause',
'Whether playback can be paused using Play or PlayPause.',
GObject.ParamFlags.READABLE,
false
),
'CanSeek': GObject.ParamSpec.boolean(
'CanSeek',
'Can Seek',
'Whether the client can control the playback position using Seek and SetPosition.',
GObject.ParamFlags.READABLE,
false
),
'CanControl': GObject.ParamSpec.boolean(
'CanControl',
'Can Control',
'Whether the media player may be controlled over this interface.',
GObject.ParamFlags.READABLE,
false
)
},
Signals: {
'Seeked': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_INT64]
}
}
}, class RemotePlayer extends GObject.Object {
_init(device, initialState) {
super._init();
this._device = device;
this._isPlaying = false;
this._ownerId = 0;
this._connection = null;
this._applicationIface = null;
this._playerIface = null;
this.parseState(initialState);
}
async export() {
try {
if (this._ownerId === 0) {
if (!this._connection) {
this._connection = await DBus.newConnection();
}
if (!this._applicationIface) {
this._applicationIface = new DBus.Interface({
g_instance: this,
g_connection: this._connection,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_info: MPRISIface
});
}
if (!this._playerIface) {
this._playerIface = new DBus.Interface({
g_instance: this,
g_connection: this._connection,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_info: MPRISPlayerIface
});
}
let name = [
this.device.name,
this.Identity
].join('').replace(/[\W]*/g, '');
this._ownerId = Gio.bus_own_name_on_connection(
this._connection,
`org.mpris.MediaPlayer2.GSConnect.${name}`,
Gio.BusNameOwnerFlags.NONE,
null,
null
);
}
} catch (e) {
logError(e);
}
}
unexport() {
if (this._ownerId !== 0) {
Gio.bus_unown_name(this._ownerId);
this._ownerId = 0;
}
if (this._applicationIface) {
this._applicationIface.destroy();
this._applicationIface = null;
}
if (this._playerIface) {
this._playerIface.destroy();
this._playerIface = null;
}
}
parseState(state) {
this._Identity = state.player;
// Metadata
let metadataChanged = false;
if (state.hasOwnProperty('title')) {
metadataChanged = true;
this._title = state.title;
}
if (state.hasOwnProperty('artist')) {
metadataChanged = true;
this._artist = state.artist;
}
if (state.hasOwnProperty('album')) {
metadataChanged = true;
this._album = state.album;
}
if (state.hasOwnProperty('length')) {
metadataChanged = true;
this._length = state.length * 1000;
}
// Probably a good idea to update this before emitting the length change
if (state.hasOwnProperty('pos')) {
this._Position = state.pos * 1000;
}
if (metadataChanged) this.notify('Metadata');
// Playback Status
if (state.hasOwnProperty('isPlaying')) {
if (this._isPlaying !== state.isPlaying) {
this._isPlaying = state.isPlaying;
this.notify('PlaybackStatus');
}
}
if (state.hasOwnProperty('canPlay')) {
if (this.CanPlay !== state.canPlay) {
this._CanPlay = state.canPlay;
this.notify('CanPlay');
}
}
if (state.hasOwnProperty('canPause')) {
if (this.CanPause !== state.canPause) {
this._CanPause = state.canPause;
this.notify('CanPause');
}
}
if (state.hasOwnProperty('canGoNext')) {
if (this.CanGoNext !== state.canGoNext) {
this._CanGoNext = state.canGoNext;
this.notify('CanGoNext');
}
}
if (state.hasOwnProperty('canGoPrevious')) {
if (this.CanGoPrevious !== state.canGoPrevious) {
this._CanGoPrevious = state.canGoPrevious;
this.notify('CanGoPrevious');
}
}
if (state.hasOwnProperty('volume')) {
this.volume = state.volume / 100;
}
if (!this._isPlaying && !this.CanControl) {
this.unexport();
} else {
this.export();
}
}
/*
* Native properties
*/
get device() {
return this._device;
}
/*
* The org.mpris.MediaPlayer2 Interface
*/
get CanQuit() {
return false;
}
get Fullscreen() {
return false;
}
get CanSetFullscreen() {
return false;
}
get CanRaise() {
return false;
}
get HasTrackList() {
return false;
}
get Identity() {
return this._Identity;
}
get DesktopEntry() {
return 'org.gnome.Shell.Extensions.GSConnect';
}
get SupportedUriSchemes() {
return [];
}
get SupportedMimeTypes() {
return [];
}
Raise() {
}
Quit() {
}
/*
* The org.mpris.MediaPlayer2.Player Interface
*/
// 'Playing', 'Paused', 'Stopped'
get PlaybackStatus() {
if (this._isPlaying) {
return 'Playing';
} else {
return 'Stopped';
}
}
// 'None', 'Track', 'Playlist'
get LoopStatus() {
return 'None';
}
set LoopStatus(status) {
this.notify('LoopStatus');
}
get Rate() {
return 1.0;
}
set Rate(rate) {
this.notify('Rate');
}
get Shuffle() {
return false;
}
set Shuffle(mode) {
this.notify('Shuffle');
}
get Metadata() {
if (this._metadata === undefined) {
this._metadata = {};
}
Object.assign(this._metadata, {
'xesam:artist': new GLib.Variant('as', [this._artist || '']),
'xesam:album': new GLib.Variant('s', this._album || ''),
'xesam:title': new GLib.Variant('s', this._title || ''),
'mpris:length': new GLib.Variant('x', this._length || 0)
});
return this._metadata;
}
get Volume() {
if (this._Volume === undefined) {
this._Volume = 1.0;
}
return this._Volume;
}
set Volume(level) {
if (this._Volume !== level) {
this._Volume = level;
this.notify('Volume');
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
setVolume: this.Volume * 100
}
});
}
}
get Position() {
if (this._Position === undefined) {
this._Position = 0;
}
return this._Position;
}
get MinimumRate() {
return 1.0;
}
get MaximumRate() {
return 1.0;
}
get CanGoNext() {
if (this._CanGoNext === undefined) {
this._CanGoNext = false;
}
return this._CanGoNext;
}
get CanGoPrevious() {
if (this._CanGoPrevious === undefined) {
this._CanGoPrevious = false;
}
return this._CanGoPrevious;
}
get CanPlay() {
if (this._CanPlay === undefined) {
this._CanPlay = false;
}
return this._CanPlay;
}
get CanPause() {
if (this._CanPause === undefined) {
this._CanPause = false;
}
return this._CanPause;
}
get CanSeek() {
if (this._CanSeek === undefined) {
this._CanSeek = false;
}
return this._CanSeek;
}
get CanControl() {
if (this._CanControl === undefined) {
this._CanControl = false;
}
return (this.CanPlay || this.CanPause);
}
Next() {
if (!this.CanControl || !this.CanGoNext) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Next'
}
});
}
Previous() {
if (!this.CanControl || !this.CanGoPrevious) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Previous'
}
});
}
Pause() {
if (!this.CanControl || !this.CanGoPause) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Pause'
}
});
}
PlayPause() {
if (!this.CanControl || !this.CanPause) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'PlayPause'
}
});
}
Stop() {
if (!this.CanControl) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Stop'
}
});
}
Play() {
if (!this.CanControl || !this.CanPlay) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Next'
}
});
}
Seek(offset) {
if (!this.CanControl || !this.CanSeek) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
Seek: offset
}
});
}
SetPosition(trackId, position) {
debug(`${this._Identity}: SetPosition(${trackId}, ${position})`);
if (!this.CanControl || !this.CanSeek) return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
SetPosition: position / 1000
}
});
}
OpenUri(uri) {
debug(`OpenUri(${uri}): Not Supported`);
}
destroy() {
if (this.__disposed === undefined) {
this.__disposed = true;
this.unexport();
if (this._connection) {
this._connection.close(null, null);
this._connection = null;
}
}
}
});

View File

@@ -0,0 +1,630 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
const NotificationUI = imports.service.ui.notification;
var Metadata = {
label: _('Notifications'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification',
incomingCapabilities: [
'kdeconnect.notification',
'kdeconnect.notification.request'
],
outgoingCapabilities: [
'kdeconnect.notification',
'kdeconnect.notification.action',
'kdeconnect.notification.reply',
'kdeconnect.notification.request'
],
actions: {
withdrawNotification: {
label: _('Cancel Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.notification']
},
closeNotification: {
label: _('Close Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.notification.request']
},
replyNotification: {
label: _('Reply Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('(ssa{ss})'),
incoming: ['kdeconnect.notification'],
outgoing: ['kdeconnect.notification.reply']
},
sendNotification: {
label: _('Send Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('a{sv}'),
incoming: [],
outgoing: ['kdeconnect.notification']
},
activateNotification: {
label: _('Activate Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('(ss)'),
incoming: [],
outgoing: ['kdeconnect.notification.action']
}
}
};
// A regex for our custom notificaiton ids
const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/;
// A list of known SMS apps
const SMS_APPS = [
// Popular apps that don't contain the string 'sms'
'com.android.messaging', // AOSP
'com.google.android.apps.messaging', // Google Messages
'com.textra', // Textra
'xyz.klinker.messenger', // Pulse
'com.calea.echo', // Mood Messenger
'com.moez.QKSMS', // QKSMS
'rpkandrodev.yaata', // YAATA
'com.tencent.mm', // WeChat
'com.viber.voip', // Viber
'com.kakao.talk', // KakaoTalk
'com.concentriclivers.mms.com.android.mms', // AOSP Clone
'fr.slvn.mms', // AOSP Clone
'com.promessage.message', //
'com.htc.sense.mms', // HTC Messages
// Known not to work with sms plugin
'org.thoughtcrime.securesms', // Signal Private Messenger
'com.samsung.android.messaging' // Samsung Messages
];
/**
* Notification Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectNotificationPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'notification');
this._session = this.service.components.get('session');
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.notification':
return this._handleNotification(packet);
case 'kdeconnect.notification.request':
return this._handleRequest(packet);
// We don't support *incoming* replies (yet)
case 'kdeconnect.notification.reply':
debug(`Not implemented: ${packet.type}`);
return;
default:
debug(`Unknown notification packet: ${packet.type}`);
}
}
connected() {
super.connected();
this.requestNotifications();
}
/**
* Handle an incoming notification or closed report.
*
* FIXME: upstream kdeconnect-android is tagging many notifications as
* `silent`, causing them to never be shown. Since we already handle
* duplicates in the Shell, we ignore that flag for now.
*/
_handleNotification(packet) {
// A report that a remote notification has been dismissed
if (packet.body.hasOwnProperty('isCancel')) {
this.device.hideNotification(packet.body.id);
// A normal, remote notification
} else {
this.receiveNotification(packet);
}
}
/**
* Handle an incoming request to close or list notifications.
*/
_handleRequest(packet) {
// A request for our notifications. This isn't implemented and would be
// pretty hard to without communicating with GNOME Shell.
if (packet.body.hasOwnProperty('request')) {
return;
// A request to close a local notification
//
// TODO: kdeconnect-android doesn't send these, and will instead send a
// kdeconnect.notification packet with isCancel and an id of "0".
//
// For clients that do support it, we report notification ids in the
// form "type|application-id|notification-id" so we can close it with
// the appropriate service.
} else if (packet.body.hasOwnProperty('cancel')) {
let [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
switch (type) {
case 'fdo':
this.service.remove_notification(parseInt(id));
break;
case 'gtk':
this.service.remove_notification(id, application);
break;
default:
debug(`Unknown notification type ${this.device.name}`);
}
}
}
/**
* Check an internal id for evidence that it's from an SMS app
*
* @param {string} - Internal notification id
* @return {boolean} - Whether the id has evidence it's from an SMS app
*/
_isSms(id) {
if (id.includes('sms')) return true;
for (let i = 0, len = SMS_APPS.length; i < len; i++) {
if (id.includes(SMS_APPS[i])) return true;
}
return false;
}
/**
* Sending Notifications
*/
async _uploadIcon(packet, icon) {
try {
// Normalize strings into GIcons
if (typeof icon === 'string') {
icon = Gio.Icon.new_for_string(icon);
}
switch (true) {
// GBytesIcon
case (icon instanceof Gio.BytesIcon):
return this._uploadBytesIcon(packet, icon.get_bytes());
// GFileIcon
case (icon instanceof Gio.FileIcon):
return this._uploadFileIcon(packet, icon.get_file());
// GThemedIcon
case (icon instanceof Gio.ThemedIcon):
return this._uploadThemedIcon(packet, icon);
default:
return this.device.sendPacket(packet);
}
} catch (e) {
logError(e);
return this.device.sendPacket(packet);
}
}
/**
* A function for uploading named icons from a GLib.Bytes object.
*
* @param {Core.Packet} packet - The packet for the notification
* @param {GLib.Bytes} bytes - The themed icon name
*/
_uploadBytesIcon(packet, bytes) {
return this._uploadIconStream(
packet,
Gio.MemoryInputStream.new_from_bytes(bytes),
bytes.get_size()
);
}
/**
* A function for uploading icons as Gio.File objects
*
* @param {Core.Packet} packet - The packet for the notification
* @param {Gio.File} file - A Gio.File object for the icon
*/
async _uploadFileIcon(packet, file) {
let stream;
try {
stream = await new Promise((resolve, reject) => {
file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
try {
resolve(file.read_finish(res));
} catch (e) {
reject(e);
}
});
});
return this._uploadIconStream(
packet,
stream,
file.query_info('standard::size', 0, null).get_size()
);
} catch (e) {
logError(e);
this.device.sendPacket(packet);
}
}
/**
* A function for uploading GThemedIcons
*
* @param {Core.Packet} packet - The packet for the notification
* @param {Gio.ThemedIcon} file - The GIcon to upload
*/
_uploadThemedIcon(packet, icon) {
let theme = Gtk.IconTheme.get_default();
for (let name of icon.names) {
// kdeconnect-android doesn't support SVGs so find the largest other
let info = theme.lookup_icon(
name,
Math.max.apply(null, theme.get_icon_sizes(name)),
Gtk.IconLookupFlags.NO_SVG
);
// Send the first icon we find from the options
if (info) {
return this._uploadFileIcon(
packet,
Gio.File.new_for_path(info.get_filename())
);
}
}
// Fallback to icon-less notification
return this.device.sendPacket(packet);
}
/**
* All icon types end up being uploaded in this function.
*
* @param {Core.Packet} packet - The packet for the notification
* @param {Gio.InputStream} stream - A stream to read the icon bytes from
* @param {number} size - Size of the icon in bytes
*/
async _uploadIconStream(packet, stream, size) {
try {
let transfer = this.device.createTransfer({
input_stream: stream,
size: size
});
let success = await transfer.upload(packet);
if (!success) {
this.device.sendPacket(packet);
}
} catch (e) {
debug(e);
this.device.sendPacket(packet);
}
}
/**
* This is called by the notification listener.
* See Notification.Listener._sendNotification()
*/
async sendNotification(notif) {
try {
// Sending notifications is forbidden
if (!this.settings.get_boolean('send-notifications')) {
return;
}
// Sending when the session is active is forbidden
if (this._session.active && !this.settings.get_boolean('send-active')) {
return;
}
// TODO: revisit application notification settings
let applications = JSON.parse(this.settings.get_string('applications'));
// An unknown application
if (!applications.hasOwnProperty(notif.appName)) {
applications[notif.appName] = {
iconName: 'system-run-symbolic',
enabled: true
};
// Only catch icons for strings and GThemedIcon
if (typeof notif.icon === 'string') {
applications[notif.appName].iconName = notif.icon;
} else if (notif.icon instanceof Gio.ThemedIcon) {
applications[notif.appName].iconName = notif.icon.names[0];
}
this.settings.set_string(
'applications',
JSON.stringify(applications)
);
}
// An enabled application
if (applications[notif.appName].enabled) {
let icon = notif.icon || null;
delete notif.icon;
let packet = {
type: 'kdeconnect.notification',
body: notif
};
await this._uploadIcon(packet, icon);
}
} catch (e) {
logError(e);
}
}
/**
* Receiving Notifications
*/
async _downloadIcon(packet) {
let file, path, stream, success, transfer;
try {
if (!packet.hasPayload()) {
return null;
}
// Save the file in the global cache
path = GLib.build_filenamev([
gsconnect.cachedir,
packet.body.payloadHash || `${Date.now()}`
]);
file = Gio.File.new_for_path(path);
// Check if we've already downloaded this icon
if (file.query_exists(null)) {
return new Gio.FileIcon({file: file});
}
// Open the file
stream = await new Promise((resolve, reject) => {
file.replace_async(null, false, 2, 0, null, (file, res) => {
try {
resolve(file.replace_finish(res));
} catch (e) {
reject(e);
}
});
});
// Download the icon
transfer = this.device.createTransfer(Object.assign({
output_stream: stream,
size: packet.payloadSize
}, packet.payloadTransferInfo));
success = await transfer.download(
packet.payloadTransferInfo.port || packet.payloadTransferInfo.uuid
);
// Return the icon if successful, delete on failure
if (success) {
return new Gio.FileIcon({file: file});
}
await new Promise((resolve, reject) => {
file.delete_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
try {
file.delete_finish(res);
} catch (e) {
}
resolve();
});
});
return null;
} catch (e) {
debug(e, this.device.name);
return null;
}
}
/**
* Receive an incoming notification
*
* @param {kdeconnect.notification} packet - The notification packet
*/
async receiveNotification(packet) {
try {
// Set defaults
let action = null;
let buttons = [];
let id = packet.body.id;
let title = packet.body.appName;
let body = `${packet.body.title}: ${packet.body.text}`;
let icon = await this._downloadIcon(packet);
// Check if this is a repliable notification
if (packet.body.requestReplyId) {
id = `${packet.body.id}|${packet.body.requestReplyId}`;
action = {
name: 'replyNotification',
parameter: new GLib.Variant('(ssa{ss})', [
packet.body.requestReplyId,
'',
{
appName: packet.body.appName,
title: packet.body.title,
text: packet.body.text
}
])
};
}
// Check if the notification has actions
if (packet.body.actions) {
buttons = packet.body.actions.map(action => {
return {
label: action,
action: 'activateNotification',
parameter: new GLib.Variant('(ss)', [id, action])
};
});
}
switch (true) {
// Special case for Missed Calls
case packet.body.id.includes('MissedCall'):
title = packet.body.title;
body = packet.body.text;
icon = icon || new Gio.ThemedIcon({name: 'call-missed-symbolic'});
break;
// Special case for SMS notifications
case this._isSms(packet.body.id):
title = packet.body.title;
body = packet.body.text;
action = {
name: 'replySms',
parameter: new GLib.Variant('s', packet.body.title)
};
icon = icon || new Gio.ThemedIcon({name: 'sms-symbolic'});
break;
// Ignore 'appName' if it's the same as 'title'
case (packet.body.appName === packet.body.title):
body = packet.body.text;
break;
}
// If we still don't have an icon use the device icon
icon = icon || new Gio.ThemedIcon({name: this.device.icon_name});
// Show the notification
this.device.showNotification({
id: id,
title: title,
body: body,
icon: icon,
action: action,
buttons: buttons
});
} catch (e) {
logError(e);
}
}
/**
* Report that a local notification has been closed/dismissed.
* TODO: kdeconnect-android doesn't handle incoming isCancel packets.
*
* @param {string} id - The local notification id
*/
withdrawNotification(id) {
this.device.sendPacket({
type: 'kdeconnect.notification',
body: {
isCancel: true,
id: id
}
});
}
/**
* Close a remote notification.
* TODO: ignore local notifications
*
* @param {string} id - The remote notification id
*/
closeNotification(id) {
this.device.sendPacket({
type: 'kdeconnect.notification.request',
body: {cancel: id}
});
}
/**
* Reply to a notification sent with a requestReplyId UUID
*
* @param {string} uuid - The requestReplyId for the repliable notification
* @param {string} message - The message to reply with
* @param {object} notification - The original notification packet
*/
replyNotification(uuid, message, notification) {
// If the message has no content, open a dialog for the user to add one
if (!message) {
let dialog = new NotificationUI.ReplyDialog({
device: this.device,
uuid: uuid,
notification: notification,
plugin: this
});
dialog.present();
// Otherwise just send the reply
} else {
this.device.sendPacket({
type: 'kdeconnect.notification.reply',
body: {
requestReplyId: uuid,
message: message
}
});
}
}
/**
* Activate a remote notification action
*
* @param {string} id - The remote notification id
* @param {string} action - The notification action (label)
*/
activateNotification(id, action) {
this.device.sendPacket({
type: 'kdeconnect.notification.action',
body: {
action: action,
key: id
}
});
}
/**
* Request the remote notifications be sent
*/
requestNotifications() {
this.device.sendPacket({
type: 'kdeconnect.notification.request',
body: {request: true}
});
}
});

View File

@@ -0,0 +1,215 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Photo'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Photo',
incomingCapabilities: ['kdeconnect.photo', 'kdeconnect.photo.request'],
outgoingCapabilities: ['kdeconnect.photo', 'kdeconnect.photo.request'],
actions: {
photo: {
label: _('Photo'),
icon_name: 'camera-photo-symbolic',
parameter_type: null,
incoming: ['kdeconnect.photo'],
outgoing: ['kdeconnect.photo.request']
}
}
};
/**
* Photo Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/photo
*
* TODO: use Cheese?
* check for /dev/video*
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectPhotoPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'photo');
// A reusable launcher for silence procs
this._launcher = new Gio.SubprocessLauncher({
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
Gio.SubprocessFlags.STDERR_SILENCE)
});
}
get camera() {
return this.settings.get_boolean('share-camera');
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.photo.request' && this.camera) {
this._sendPhoto();
} else if (packet.type === 'kdeconnect.photo') {
this._receivePhoto(packet);
}
}
_ensureReceiveDirectory() {
let receiveDir = this.settings.get_string('receive-directory');
// Ensure a directory is set
if (!receiveDir) {
receiveDir = GLib.get_user_special_dir(
GLib.UserDirectory.DIRECTORY_PICTURES
);
// Fallback to ~/Pictures
let homeDir = GLib.get_home_dir();
if (!receiveDir || receiveDir === homeDir) {
receiveDir = GLib.build_filenamev([homeDir, 'Pictures']);
}
this.settings.set_string('receive-directory', receiveDir);
}
// Ensure the directory exists
if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR)) {
GLib.mkdir_with_parents(receiveDir, 448);
}
return receiveDir;
}
_getFile(filename) {
let dirpath = this._ensureReceiveDirectory();
let basepath = GLib.build_filenamev([dirpath, filename]);
let filepath = basepath;
let copyNum = 0;
while (GLib.file_test(filepath, GLib.FileTest.EXISTS)) {
copyNum += 1;
filepath = `${basepath} (${copyNum})`;
}
return Gio.File.new_for_path(filepath);
}
async _receivePhoto(packet) {
let file, stream, success, transfer;
try {
// Remote device cancelled the photo operation
if (packet.body.cancel) return;
file = this._getFile(packet.body.filename);
stream = await new Promise((resolve, reject) => {
file.replace_async(null, false, 0, 0, null, (file, res) => {
try {
resolve(file.replace_finish(res));
} catch (e) {
reject(e);
}
});
});
transfer = this.device.createTransfer(Object.assign({
output_stream: stream,
size: packet.payloadSize
}, packet.payloadTransferInfo));
// Start transfer
success = await transfer.download(packet.payloadTransferInfo.port);
// Open the photo on success
if (success) {
let uri = file.get_uri();
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
// Clean up the downloaded file on failure
} else {
file.delete(null);
}
} catch (e) {
logError(e);
}
}
/**
* Take a photo using the Webcam, return a file path
*/
_takePhoto() {
return new Promise((resolve, reject) => {
let time = GLib.DateTime.new_now_local().format('%T');
let path = GLib.build_filenamev([GLib.get_tmp_dir(), `${time}.jpg`]);
let proc = this._launcher.spawnv([
gsconnect.metadata.bin.ffmpeg,
'-f', 'video4linux2',
'-ss', '0:0:2',
'-i', '/dev/video0',
'-frames', '1',
path
]);
proc.wait_check_async(null, (proc, res) => {
try {
proc.wait_check_finish(res);
resolve(path);
} catch (e) {
reject(e);
}
});
});
}
async _sendPhoto() {
let file, path, stream, transfer;
try {
path = await this._takePhoto();
if (path.startsWith('file://')) {
file = Gio.File.new_for_uri(path);
} else {
file = Gio.File.new_for_path(path);
}
stream = await new Promise((resolve, reject) => {
file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
try {
resolve(file.read_finish(res));
} catch (e) {
reject(e);
}
});
});
transfer = this.device.createTransfer({
input_stream: stream,
size: file.query_info('standard::size', 0, null).get_size()
});
await transfer.upload({
type: 'kdeconnect.photo',
body: {
filename: file.get_basename()
}
});
} catch (e) {
debug(e, this.device.name);
}
}
photo() {
this.device.sendPacket({
type: 'kdeconnect.photo.request',
body: {}
});
}
});

View File

@@ -0,0 +1,72 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Ping'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Ping',
incomingCapabilities: ['kdeconnect.ping'],
outgoingCapabilities: ['kdeconnect.ping'],
actions: {
ping: {
label: _('Ping'),
icon_name: 'dialog-information-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.ping']
}
}
};
/**
* Ping Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/ping
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectPingPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'ping');
}
handlePacket(packet) {
// Notification
let notif = {
title: this.device.name,
body: _('Ping'),
icon: new Gio.ThemedIcon({name: `${this.device.icon_name}-symbolic`})
};
if (packet.body.message) {
// TRANSLATORS: An optional message accompanying a ping, rarely if ever used
// eg. Ping: A message sent with ping
notif.body = _('Ping: %s').format(packet.body.message);
}
this.device.showNotification(notif);
}
ping(message = '') {
debug(message);
let packet = {
type: 'kdeconnect.ping',
body: {}
};
if (message.length) {
packet.body.message = message;
}
this.device.sendPacket(packet);
}
});

View File

@@ -0,0 +1,44 @@
'use strict';
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Presentation'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Presenter',
incomingCapabilities: ['kdeconnect.presenter'],
outgoingCapabilities: [],
actions: {}
};
/**
* Presenter Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/presenter
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/PresenterPlugin/
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectPresenterPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'presenter');
this._input = this.service.components.get('input');
}
handlePacket(packet) {
if (packet.body.hasOwnProperty('dx')) {
this._input.movePointer(
packet.body.dx * 1000,
packet.body.dy * 1000
);
} else if (packet.body.stop) {
// Currently unsupported and unnecessary as we just re-use the mouse
// pointer instead of showing an arbitrary window.
}
}
});

View File

@@ -0,0 +1,229 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('Run Commands'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.RunCommand',
incomingCapabilities: ['kdeconnect.runcommand', 'kdeconnect.runcommand.request'],
outgoingCapabilities: ['kdeconnect.runcommand', 'kdeconnect.runcommand.request'],
actions: {
commands: {
label: _('Commands'),
icon_name: 'system-run-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: ['kdeconnect.runcommand'],
outgoing: ['kdeconnect.runcommand.request']
},
executeCommand: {
label: _('Commands'),
icon_name: 'system-run-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: ['kdeconnect.runcommand'],
outgoing: ['kdeconnect.runcommand.request']
}
}
};
/**
* RunCommand Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/remotecommands
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/runcommand
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectRunCommandPlugin',
Properties: {
'remote-commands': GObject.param_spec_variant(
'remote-commands',
'Remote Command List',
'A list of the device\'s remote commands',
new GLib.VariantType('a{sv}'),
null,
GObject.ParamFlags.READABLE
)
}
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'runcommand');
// Local Commands
this._commandListChangedId = this.settings.connect(
'changed::command-list',
this.sendCommandList.bind(this)
);
// We cache remote commands so they can be used in the settings even
// when the device is offline.
this._remote_commands = {};
this.cacheProperties(['_remote_commands']);
}
get remote_commands() {
return this._remote_commands;
}
handlePacket(packet) {
// A request...
if (packet.type === 'kdeconnect.runcommand.request') {
// ...for the local command list
if (packet.body.hasOwnProperty('requestCommandList')) {
this.sendCommandList();
// ...to execute a command
} else if (packet.body.hasOwnProperty('key')) {
this._handleCommand(packet.body.key);
}
// A response to a request for the remote command list
} else if (packet.type === 'kdeconnect.runcommand') {
this._handleCommandList(packet.body.commandList);
}
}
connected() {
super.connected();
// Disable the commands action until we know better
this.sendCommandList();
this.requestCommandList();
this._handleCommandList(this.remote_commands);
}
clearCache() {
this._remote_commands = {};
this.__cache_write();
this.notify('remote-commands');
}
cacheLoaded() {
if (this.device.connected) {
this.connected();
}
}
/**
* Handle a request to execute the local command with the UUID @key
* @param {String} key - The UUID of the local command
*/
_handleCommand(key) {
try {
let commandList = this.settings.get_value('command-list').recursiveUnpack();
if (!commandList.hasOwnProperty(key)) {
throw new Error(`Unknown command: ${key}`);
}
this.device.launchProcess([
'/bin/sh',
'-c',
commandList[key].command
]);
} catch (e) {
logError(e, this.device.name);
}
}
/**
* Parse the response to a request for the remote command list. Remove the
* command menu if there are no commands, otherwise amend the menu.
*/
_handleCommandList(commandList) {
this._remote_commands = commandList;
this.notify('remote-commands');
let commandEntries = Object.entries(this.remote_commands);
// If there are no commands, hide the menu by disabling the action
this.device.lookup_action('commands').enabled = (commandEntries.length > 0);
// Commands Submenu
let submenu = new Gio.Menu();
for (let [uuid, info] of commandEntries) {
let item = new Gio.MenuItem();
item.set_label(info.name);
item.set_icon(
new Gio.ThemedIcon({name: 'application-x-executable-symbolic'})
);
item.set_detailed_action(`device.executeCommand::${uuid}`);
submenu.append_item(item);
}
// Commands Item
let item = new Gio.MenuItem();
item.set_detailed_action('device.commands::menu');
item.set_attribute_value(
'hidden-when',
new GLib.Variant('s', 'action-disabled')
);
item.set_icon(
new Gio.ThemedIcon({name: 'system-run-symbolic'})
);
item.set_label(_('Commands'));
item.set_submenu(submenu);
// If the submenu item is already present it will be replaced
let index = this.device.settings.get_strv('menu-actions').indexOf('commands');
if (index > -1) {
this.device.removeMenuAction('commands');
this.device.addMenuItem(item, index);
}
}
/**
* Placeholder function for command action
*/
commands() {}
/**
* Send a request to execute the remote command with the UUID @key
* @param {String} key - The UUID of the remote command
*/
executeCommand(key) {
this.device.sendPacket({
type: 'kdeconnect.runcommand.request',
body: {key: key}
});
}
/**
* Send a request for the remote command list
*/
requestCommandList() {
this.device.sendPacket({
type: 'kdeconnect.runcommand.request',
body: {requestCommandList: true}
});
}
/**
* Send the local command list
*/
sendCommandList() {
let commands = this.settings.get_value('command-list').recursiveUnpack();
this.device.sendPacket({
type: 'kdeconnect.runcommand',
body: {commandList: commands}
});
}
destroy() {
if (this._commandListChangedId) {
this.settings.disconnect(this._commandListChangedId);
}
super.destroy();
}
});

View File

@@ -0,0 +1,510 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('SFTP'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
incomingCapabilities: ['kdeconnect.sftp'],
outgoingCapabilities: ['kdeconnect.sftp.request'],
actions: {
mount: {
label: _('Mount'),
icon_name: 'folder-remote-symbolic',
parameter_type: null,
incoming: ['kdeconnect.sftp'],
outgoing: ['kdeconnect.sftp.request']
},
unmount: {
label: _('Unmount'),
icon_name: 'media-eject-symbolic',
parameter_type: null,
incoming: ['kdeconnect.sftp'],
outgoing: ['kdeconnect.sftp.request']
}
}
};
/**
* SFTP Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
*/
var Plugin = GObject.registerClass({
Name: 'GSConnectSFTPPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'sftp');
// A reusable launcher for ssh processes
this._launcher = new Gio.SubprocessLauncher({
flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE
});
this._mounting = false;
}
get info() {
if (this._info === undefined) {
this._info = {
directories: {},
mount: null,
regex: null,
uri: null
};
}
return this._info;
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.sftp') {
// There was an error mounting the filesystem
if (packet.body.errorMessage) {
this.device.showNotification({
id: 'sftp-error',
title: `${this.device.name}: ${Metadata.label}`,
body: packet.body.errorMessage,
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
priority: Gio.NotificationPriority.URGENT
});
// Ensure we don't mount on top of an existing mount
} else if (this.info.mount === null) {
this._mount(packet.body);
}
}
}
connected() {
super.connected();
// Disable for all bluetooth connections
if (this.device.connection_type !== 'lan') {
this.device.lookup_action('mount').enabled = false;
this.device.lookup_action('unmount').enabled = false;
// Request a mount
} else {
this.mount();
}
}
disconnected() {
super.disconnected();
this.unmount();
}
/**
* Parse the connection info
*
* @param {object} info - The body of a kdeconnect.sftp packet
*/
_parseInfo(info) {
if (!this.device.connected) {
throw new Gio.IOErrorEnum({
message: _('Device is disconnected'),
code: Gio.IOErrorEnum.CONNECTION_CLOSED
});
}
this._info = info;
this.info.ip = this.device.channel.host;
this.info.directories = {};
this.info.mount = null;
this.info.regex = new RegExp(
'sftp://(' + this.info.ip + '):(1739|17[4-5][0-9]|176[0-4])'
);
this.info.uri = 'sftp://' + this.info.ip + ':' + this.info.port + '/';
// If 'multiPaths' is present setup a local URI for each
if (info.hasOwnProperty('multiPaths')) {
for (let i = 0; i < info.multiPaths.length; i++) {
let name = info.pathNames[i];
let path = info.multiPaths[i];
this.info.directories[name] = this.info.uri + path;
}
// If 'multiPaths' is missing use 'path' and assume a Camera folder
} else {
let uri = this.info.uri + this.info.path;
this.info.directories[_('All files')] = uri;
this.info.directories[_('Camera pictures')] = uri + 'DCIM/Camera';
}
}
_onAskQuestion(op, message, choices) {
op.reply(Gio.MountOperationResult.HANDLED);
}
_onAskPassword(op, message, user, domain, flags) {
op.reply(Gio.MountOperationResult.HANDLED);
}
async _mount(info) {
try {
// If mounting is already in progress, let that fail before retrying
if (this._mounting) return;
this._mounting = true;
// Parse the connection info
this._parseInfo(info);
// Ensure the private key is in the keyring
await this._addPrivateKey();
// Create a new mount operation
let op = new Gio.MountOperation({
username: info.user,
password: info.password,
password_save: Gio.PasswordSave.NEVER
});
// Auto-accept new host keys and password requests
let questionId = op.connect('ask-question', this._onAskQuestion);
let passwordId = op.connect('ask-password', this._onAskPassword);
// This is the actual call to mount the device
await new Promise((resolve, reject) => {
let file = Gio.File.new_for_uri(this.info.uri);
file.mount_enclosing_volume(0, op, null, (file, res) => {
try {
op.disconnect(questionId);
op.disconnect(passwordId);
resolve(file.mount_enclosing_volume_finish(res));
} catch (e) {
// Special case when the GMount didn't unmount properly
// but is still on the same port and can be reused.
if (e.code && e.code === Gio.IOErrorEnum.ALREADY_MOUNTED) {
resolve(true);
// There's a good chance this is a host key verification
// error; regardless we'll remove the key for security.
} else {
this._removeHostKey(this.info.ip);
reject(e);
}
}
});
});
// Get the GMount from GVolumeMonitor
let monitor = Gio.VolumeMonitor.get();
for (let mount of monitor.get_mounts()) {
let uri = mount.get_root().get_uri();
// This is our GMount
if (this.info.uri === uri) {
this.info.mount = mount;
this.info.mount.connect(
'unmounted',
this.unmount.bind(this)
);
this._addSymlink(mount);
// This is one of our old mounts
} else if (this.info.regex.test(uri)) {
debug(`Remove stale mount at ${uri}`);
await this._unmount(mount);
}
}
// Populate the menu
this._addSubmenu();
} catch (e) {
logError(e, this.device.name);
this.unmount();
} finally {
this._mounting = false;
}
}
_unmount(mount = null) {
if (!mount) return Promise.resolve();
return new Promise((resolve, reject) => {
let op = new Gio.MountOperation();
mount.unmount_with_operation(1, op, null, (mount, res) => {
try {
mount.unmount_with_operation_finish(res);
resolve();
} catch (e) {
debug(e);
resolve();
}
});
});
}
/**
* Add GSConnect's private key identity to the authentication agent so our
* identity can be verified by Android during private key authentication.
*/
_addPrivateKey() {
let ssh_add = this._launcher.spawnv([
gsconnect.metadata.bin.ssh_add,
GLib.build_filenamev([gsconnect.configdir, 'private.pem'])
]);
return new Promise((resolve, reject) => {
ssh_add.communicate_utf8_async(null, null, (proc, res) => {
try {
let result = proc.communicate_utf8_finish(res)[1].trim();
if (proc.get_exit_status() !== 0) {
debug(result, this.device.name);
}
resolve();
} catch (e) {
reject(e);
}
});
});
}
/**
* Remove all host keys from ~/.ssh/known_hosts for @host in the port range
* used by KDE Connect (1739-1764).
*
* @param {string} host - A hostname or IP address
*/
async _removeHostKey(host) {
for (let port = 1739; port <= 1764; port++) {
try {
let ssh_keygen = this._launcher.spawnv([
gsconnect.metadata.bin.ssh_keygen,
'-R',
`[${host}]:${port}`
]);
await new Promise((resolve, reject) => {
ssh_keygen.wait_check_async(null, (proc, res) => {
try {
resolve(proc.wait_check_finish(res));
} catch (e) {
reject(e);
}
});
});
} catch (e) {
debug(e);
}
}
}
/**
* Mount menu helpers
*/
_getUnmountSection() {
if (this._unmountSection === undefined) {
this._unmountSection = new Gio.Menu();
let unmountItem = new Gio.MenuItem();
unmountItem.set_label(Metadata.actions.unmount.label);
unmountItem.set_icon(new Gio.ThemedIcon({
name: Metadata.actions.unmount.icon_name
}));
unmountItem.set_detailed_action('device.unmount');
this._unmountSection.append_item(unmountItem);
}
return this._unmountSection;
}
_getMountedIcon() {
if (this._mountedIcon === undefined) {
this._mountedIcon = new Gio.EmblemedIcon({
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'})
});
// TODO: this emblem often isn't very visible
let emblem = new Gio.Emblem({
icon: new Gio.ThemedIcon({name: 'emblem-default'})
});
this._mountedIcon.add_emblem(emblem);
}
return this._mountedIcon;
}
_addSubmenu() {
try {
// Directories Section
let dirSection = new Gio.Menu();
for (let [name, uri] of Object.entries(this.info.directories)) {
dirSection.append(name, `device.openPath::${uri}`);
}
// Unmount Section
let unmountSection = this._getUnmountSection();
// Files Submenu
let filesSubmenu = new Gio.Menu();
filesSubmenu.append_section(null, dirSection);
filesSubmenu.append_section(null, unmountSection);
// Files Item
let filesItem = new Gio.MenuItem();
filesItem.set_detailed_action('device.mount');
filesItem.set_icon(this._getMountedIcon());
filesItem.set_label(_('Files'));
filesItem.set_submenu(filesSubmenu);
this.device.replaceMenuAction('device.mount', filesItem);
} catch (e) {
logError(e);
}
}
_removeSubmenu() {
try {
let index = this.device.removeMenuAction('device.mount');
let action = this.device.lookup_action('mount');
if (action !== null) {
this.device.addMenuAction(
action,
index,
Metadata.actions.mount.label,
Metadata.actions.mount.icon_name
);
}
} catch (e) {
logError(e);
}
}
/**
* Create a symbolic link referring to the device by name
*/
async _addSymlink(mount) {
try {
let by_name_dir = Gio.File.new_for_path(
gsconnect.runtimedir + '/by-name/'
);
try {
by_name_dir.make_directory_with_parents(null);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) {
throw e;
}
}
// Replace path separator with a Unicode lookalike:
let safe_device_name = this.device.name.replace('/', '');
if (safe_device_name === '.') {
safe_device_name = '·';
} else if (safe_device_name === '..') {
safe_device_name = '··';
}
let link_target = mount.get_root().get_path();
let link = Gio.File.new_for_path(
by_name_dir.get_path() + '/' + safe_device_name
);
// Check for and remove any existing stale link
try {
let link_stat = await new Promise((resolve, reject) => {
link.query_info_async(
'standard::symlink-target',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
null,
(link, res) => {
try {
resolve(link.query_info_finish(res));
} catch (e) {
reject(e);
}
},
);
});
if (link_stat.get_symlink_target() === link_target) {
return;
}
await new Promise((resolve, reject) => {
link.delete_async(
GLib.PRIORITY_DEFAULT,
null,
(link, res) => {
try {
resolve(link.delete_finish(res));
} catch (e) {
reject(e);
}
},
);
});
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
throw e;
}
}
link.make_symbolic_link(link_target, null);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Send a request to mount the remote device
*/
mount() {
this.device.sendPacket({
type: 'kdeconnect.sftp.request',
body: {
startBrowsing: true
}
});
}
/**
* Remove the menu items, unmount the filesystem, replace the mount item
*/
async unmount() {
try {
if (this.info.mount === null) {
return;
}
let mount = this.info.mount;
this._removeSubmenu();
this._info = undefined;
this._mounting = false;
await this._unmount(mount);
} catch (e) {
debug(e);
}
}
destroy() {
this.unmount();
super.destroy();
}
});

View File

@@ -0,0 +1,521 @@
'use strict';
const GdkPixbuf = imports.gi.GdkPixbuf;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
const URI = imports.utils.uri;
var Metadata = {
label: _('Share'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share',
incomingCapabilities: ['kdeconnect.share.request'],
outgoingCapabilities: ['kdeconnect.share.request'],
actions: {
share: {
label: _('Share'),
icon_name: 'send-to-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.share.request']
},
shareFile: {
label: _('Share File'),
icon_name: 'document-send-symbolic',
parameter_type: new GLib.VariantType('(sb)'),
incoming: [],
outgoing: ['kdeconnect.share.request']
},
shareText: {
label: _('Share Text'),
icon_name: 'send-to-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.share.request']
},
shareUri: {
label: _('Share Link'),
icon_name: 'send-to-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.share.request']
}
}
};
/**
* Share Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/share
*
* TODO: receiving 'text' TODO: Window with textview & 'Copy to Clipboard..
* https://github.com/KDE/kdeconnect-kde/commit/28f11bd5c9a717fb9fbb3f02ddd6cea62021d055
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectSharePlugin',
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'share');
}
_ensureReceiveDirectory() {
let receiveDir = this.settings.get_string('receive-directory');
// Ensure a directory is set
if (!receiveDir) {
receiveDir = GLib.get_user_special_dir(
GLib.UserDirectory.DIRECTORY_DOWNLOAD
);
// Fallback to ~/Downloads
let homeDir = GLib.get_home_dir();
if (!receiveDir || receiveDir === homeDir) {
receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
}
this.settings.set_string('receive-directory', receiveDir);
}
// Ensure the directory exists
if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR)) {
GLib.mkdir_with_parents(receiveDir, 448);
}
return receiveDir;
}
_getFile(filename) {
let dirpath = this._ensureReceiveDirectory();
let basepath = GLib.build_filenamev([dirpath, filename]);
let filepath = basepath;
let copyNum = 0;
while (GLib.file_test(filepath, GLib.FileTest.EXISTS)) {
copyNum += 1;
filepath = `${basepath} (${copyNum})`;
}
return Gio.File.new_for_path(filepath);
}
async _refuseFile(packet) {
try {
await this.device.rejectTransfer(packet);
this.device.showNotification({
id: `${Date.now()}`,
title: _('Transfer Failed'),
// TRANSLATORS: eg. Google Pixel is not allowed to upload files
body: _('%s is not allowed to upload files').format(
this.device.name
),
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'})
});
} catch (e) {
logError(e, this.device.name);
}
}
async _handleFile(packet) {
let file, stream, success, transfer;
let title, body, iconName;
let buttons = [];
try {
file = this._getFile(packet.body.filename);
stream = await new Promise((resolve, reject) => {
file.replace_async(null, false, 0, 0, null, (file, res) => {
try {
resolve(file.replace_finish(res));
} catch (e) {
reject(e);
}
});
});
transfer = this.device.createTransfer(Object.assign({
output_stream: stream,
size: packet.payloadSize
}, packet.payloadTransferInfo));
// Notify that we're about to start the transfer
this.device.showNotification({
id: transfer.uuid,
title: _('Transferring File'),
// TRANSLATORS: eg. Receiving 'book.pdf' from Google Pixel
body: _('Receiving “%s” from %s').format(
packet.body.filename,
this.device.name
),
buttons: [{
label: _('Cancel'),
action: 'cancelTransfer',
parameter: new GLib.Variant('s', transfer.uuid)
}],
icon: new Gio.ThemedIcon({name: 'document-save-symbolic'})
});
// Start transfer
success = await transfer.download();
this.device.hideNotification(transfer.uuid);
// We've been asked to open this directly
if (success && packet.body.open) {
let uri = file.get_uri();
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
return;
}
// We'll show a notification (success or failure)
if (success) {
title = _('Transfer Successful');
// TRANSLATORS: eg. Received 'book.pdf' from Google Pixel
body = _('Received “%s” from %s').format(
packet.body.filename,
this.device.name
);
buttons = [
{
label: _('Open Folder'),
action: 'openPath',
parameter: new GLib.Variant('s', file.get_parent().get_uri())
},
{
label: _('Open File'),
action: 'openPath',
parameter: new GLib.Variant('s', file.get_uri())
}
];
iconName = 'document-save-symbolic';
} else {
title = _('Transfer Failed');
// TRANSLATORS: eg. Failed to receive 'book.pdf' from Google Pixel
body = _('Failed to receive “%s” from %s').format(
packet.body.filename,
this.device.name
);
iconName = 'dialog-warning-symbolic';
// Clean up the downloaded file on failure
file.delete(null);
}
this.device.showNotification({
id: transfer.uuid,
title: title,
body: body,
buttons: buttons,
icon: new Gio.ThemedIcon({name: iconName})
});
} catch (e) {
logError(e, this.device.name);
}
}
_handleUri(packet) {
let uri = packet.body.url;
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
_handleText(packet) {
let dialog = new Gtk.MessageDialog({
text: _('Text Shared By %s').format(this.device.name),
secondary_text: URI.linkify(packet.body.text),
secondary_use_markup: true,
buttons: Gtk.ButtonsType.CLOSE
});
dialog.message_area.get_children()[1].selectable = true;
dialog.set_keep_above(true);
dialog.connect('response', (dialog) => dialog.destroy());
dialog.show();
}
/**
* Packet dispatch
*/
handlePacket(packet) {
if (packet.body.hasOwnProperty('filename')) {
if (this.settings.get_boolean('receive-files')) {
this._handleFile(packet);
} else {
this._refuseFile(packet);
}
} else if (packet.body.hasOwnProperty('text')) {
this._handleText(packet);
} else if (packet.body.hasOwnProperty('url')) {
this._handleUri(packet);
}
}
/**
* Remote methods
*/
share() {
let dialog = new FileChooserDialog(this.device);
dialog.show();
}
/**
* Share local file path or URI
*
* @param {string} path - Local file path or URI
* @param {boolean} open - Whether the file should be opened after transfer
*/
async shareFile(path, open = false) {
let file, stream, success, transfer;
let title, body, iconName;
try {
if (path.includes('://')) {
file = Gio.File.new_for_uri(path);
} else {
file = Gio.File.new_for_path(path);
}
stream = await new Promise((resolve, reject) => {
file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
try {
resolve(file.read_finish(res));
} catch (e) {
reject(e);
}
});
});
transfer = this.device.createTransfer({
input_stream: stream,
size: file.query_info('standard::size', 0, null).get_size()
});
// Notify that we're about to start the transfer
this.device.showNotification({
id: transfer.uuid,
title: _('Transferring File'),
// TRANSLATORS: eg. Sending 'book.pdf' to Google Pixel
body: _('Sending “%s” to %s').format(
file.get_basename(),
this.device.name
),
buttons: [{
label: _('Cancel'),
action: 'cancelTransfer',
parameter: new GLib.Variant('s', transfer.uuid)
}],
icon: new Gio.ThemedIcon({name: 'document-send-symbolic'})
});
success = await transfer.upload({
type: 'kdeconnect.share.request',
body: {
filename: file.get_basename(),
open: open
}
});
if (success) {
title = _('Transfer Successful');
// TRANSLATORS: eg. Sent "book.pdf" to Google Pixel
body = _('Sent “%s” to %s').format(
file.get_basename(),
this.device.name
);
iconName = 'document-send-symbolic';
} else {
title = _('Transfer Failed');
// TRANSLATORS: eg. Failed to send "book.pdf" to Google Pixel
body = _('Failed to send “%s” to %s').format(
file.get_basename(),
this.device.name
);
iconName = 'dialog-warning-symbolic';
}
this.device.hideNotification(transfer.uuid);
this.device.showNotification({
id: transfer.uuid,
title: title,
body: body,
icon: new Gio.ThemedIcon({name: iconName})
});
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Share a string of text. Remote behaviour is undefined.
*
* @param {string} text - A string of unicode text
*/
shareText(text) {
this.device.sendPacket({
type: 'kdeconnect.share.request',
body: {text: text}
});
}
/**
* Share a URI. Generally the remote device opens it with the scheme default
*
* @param {string} uri - Currently http(s) and tel: URIs are supported
*/
shareUri(uri) {
switch (true) {
// Currently only pass http(s)/tel URIs
case uri.startsWith('http://'):
case uri.startsWith('https://'):
case uri.startsWith('tel:'):
break;
// Redirect local file URIs
case uri.startsWith('file://'):
return this.sendFile(uri);
// Assume HTTPS
default:
uri = `https://${uri}`;
}
this.device.sendPacket({
type: 'kdeconnect.share.request',
body: {url: uri}
});
}
});
/** A simple FileChooserDialog for sharing files */
var FileChooserDialog = GObject.registerClass({
GTypeName: 'GSConnectShareFileChooserDialog',
}, class FileChooserDialog extends Gtk.FileChooserDialog {
_init(device) {
super._init({
// TRANSLATORS: eg. Send files to Google Pixel
title: _('Send files to %s').format(device.name),
select_multiple: true,
extra_widget: new Gtk.CheckButton({
// TRANSLATORS: Mark the file to be opened once completed
label: _('Open when done'),
visible: true
}),
use_preview_label: false
});
this.device = device;
// Align checkbox with sidebar
let box = this.get_content_area().get_children()[0].get_children()[0];
let paned = box.get_children()[0];
paned.bind_property(
'position',
this.extra_widget,
'margin-left',
GObject.BindingFlags.SYNC_CREATE
);
// Preview Widget
this.preview_widget = new Gtk.Image();
this.preview_widget_active = false;
this.connect('update-preview', this._onUpdatePreview);
// URI entry
this._uriEntry = new Gtk.Entry({
placeholder_text: 'https://',
hexpand: true,
visible: true
});
this._uriEntry.connect('activate', this._sendLink.bind(this));
// URI/File toggle
this._uriButton = new Gtk.ToggleButton({
image: new Gtk.Image({
icon_name: 'web-browser-symbolic',
pixel_size: 16
}),
valign: Gtk.Align.CENTER,
// TRANSLATORS: eg. Send a link to Google Pixel
tooltip_text: _('Send a link to %s').format(device.name),
visible: true
});
this._uriButton.connect('toggled', this._onUriButtonToggled.bind(this));
this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
let sendButton = this.add_button(_('Send'), Gtk.ResponseType.OK);
sendButton.connect('clicked', this._sendLink.bind(this));
this.get_header_bar().pack_end(this._uriButton);
this.set_default_response(Gtk.ResponseType.OK);
}
_onUpdatePreview(chooser) {
try {
let pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
chooser.get_preview_filename(),
chooser.get_scale_factor() * 128,
-1
);
chooser.preview_widget.pixbuf = pixbuf;
chooser.preview_widget.visible = true;
chooser.preview_widget_active = true;
} catch (e) {
chooser.preview_widget.visible = false;
chooser.preview_widget_active = false;
}
}
_onUriButtonToggled(button) {
let header = this.get_header_bar();
// Show the URL entry
if (button.active) {
this.extra_widget.sensitive = false;
header.set_custom_title(this._uriEntry);
this.set_response_sensitive(Gtk.ResponseType.OK, true);
// Hide the URL entry
} else {
header.set_custom_title(null);
this.set_response_sensitive(
Gtk.ResponseType.OK,
this.get_uris().length > 1
);
this.extra_widget.sensitive = true;
}
}
_sendLink(widget) {
if (this._uriButton.active && this._uriEntry.text.length) {
this.response(1);
}
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
this.get_uris().map(uri => {
let parameter = new GLib.Variant(
'(sb)',
[uri, this.extra_widget.active]
);
this.device.activate_action('shareFile', parameter);
});
} else if (response_id === 1) {
let parameter = new GLib.Variant('s', this._uriEntry.text);
this.device.activate_action('shareUri', parameter);
}
this.destroy();
}
});

View File

@@ -0,0 +1,530 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
const Messaging = imports.service.ui.messaging;
const TelephonyUI = imports.service.ui.telephony;
const URI = imports.utils.uri;
var Metadata = {
label: _('SMS'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SMS',
incomingCapabilities: [
'kdeconnect.sms.messages'
],
outgoingCapabilities: [
'kdeconnect.sms.request',
'kdeconnect.sms.request_conversation',
'kdeconnect.sms.request_conversations'
],
actions: {
// SMS Actions
sms: {
label: _('Messaging'),
icon_name: 'sms-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.sms.request']
},
uriSms: {
label: _('New SMS (URI)'),
icon_name: 'sms-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request']
},
replySms: {
label: _('Reply SMS'),
icon_name: 'sms-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request']
},
sendMessage: {
label: _('Send Message'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('(aa{sv})'),
incoming: [],
outgoing: ['kdeconnect.sms.request']
},
sendSms: {
label: _('Send SMS'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('(ss)'),
incoming: [],
outgoing: ['kdeconnect.sms.request']
},
shareSms: {
label: _('Share SMS'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request']
}
}
};
/**
* SMS Message event type. Currently all events are TEXT_MESSAGE.
*
* TEXT_MESSAGE: Has a "body" field which contains pure, human-readable text
*/
var MessageEvent = {
TEXT_MESSAGE: 0x1
};
/**
* SMS Message status. READ/UNREAD match the 'read' field from the Android App
* message packet.
*
* UNREAD: A message not marked as read
* READ: A message marked as read
*/
var MessageStatus = {
UNREAD: 0,
READ: 1
};
/**
* SMS Message direction. IN/OUT match the 'type' field from the Android App
* message packet.
*
* See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
*
* IN: An incoming message
* OUT: An outgoing message
*/
var MessageBox = {
ALL: 0,
INBOX: 1,
SENT: 2,
DRAFT: 3,
OUTBOX: 4,
FAILED: 5
};
/**
* SMS Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sms
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SMSPlugin/
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectSMSPlugin',
Properties: {
'threads': GObject.param_spec_variant(
'threads',
'Conversation List',
'A list of threads',
new GLib.VariantType('aa{sv}'),
null,
GObject.ParamFlags.READABLE
)
}
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'sms');
this.threads = {};
this.cacheProperties(['threads']);
this._version = 1;
}
get window() {
if (this.settings.get_boolean('legacy-sms')) {
return new TelephonyUI.LegacyMessagingDialog({
device: this.device,
plugin: this
});
}
if (this._window === undefined) {
this._window = new Messaging.Window({
application: this.service,
device: this.device,
plugin: this
});
}
return this._window;
}
handlePacket(packet) {
// Currently only one incoming packet type
if (packet.type === 'kdeconnect.sms.messages') {
this._handleMessages(packet.body.messages);
}
}
clearCache() {
this.threads = {};
this.__cache_write();
this.notify('threads');
}
cacheLoaded() {
this.notify('threads');
}
connected() {
super.connected();
this.requestConversations();
}
/**
* Handle a digest of threads.
*
* @param {Object[]} messages - A list of message objects
* @param {string[]} thread_ids - A list of thread IDs as strings
*/
_handleDigest(messages, thread_ids) {
// Prune threads
for (let thread_id of Object.keys(this.threads)) {
if (!thread_ids.includes(thread_id)) {
delete this.threads[thread_id];
}
}
// Request each new or newer thread
for (let i = 0, len = messages.length; i < len; i++) {
let message = messages[i];
let cache = this.threads[message.thread_id];
// If this message is marked read and it's for an existing
// thread, we should mark the rest in this thread as read
if (cache && message.read === MessageStatus.READ) {
cache.forEach(msg => msg.read = MessageStatus.READ);
}
// If we don't have a thread for this message or it's newer
// than the last message in the cache, request the thread
if (!cache || cache[cache.length - 1].date < message.date) {
this.requestConversation(message.thread_id);
}
}
this.__cache_write();
this.notify('threads');
}
/**
* Handle a new single message
*
* @param {Object} message - A message object
*/
_handleMessage(message) {
let conversation = null;
// If the window is open, try and find an active conversation
if (this._window) {
conversation = this._window.getConversationForMessage(message);
}
// If there's an active conversation, we should log the message now
if (conversation) {
conversation.logNext(message);
}
}
/**
* Parse a conversation (thread of messages) and sort them
*
* @param {Object[]} thread - A list of sms message objects from a thread
*/
_handleThread(thread) {
try {
// If there are no addresses this will cause major problems...
if (!thread[0].addresses || !thread[0].addresses[0]) return;
let thread_id = thread[0].thread_id;
let cache = this.threads[thread_id] || [];
// Handle each message
for (let i = 0, len = thread.length; i < len; i++) {
let message = thread[i];
// TODO: invalid MessageBox
if (message.type < 0 || message.type > 5) continue;
// If the message exists, just update it
let cacheMessage = cache.find(m => m.date === message.date);
if (cacheMessage) {
Object.assign(cacheMessage, message);
} else {
cache.push(message);
this._handleMessage(message);
}
}
// Sort the thread by ascending date and write to cache
this.threads[thread_id] = cache.sort((a, b) => {
return (a.date < b.date) ? -1 : 1;
});
this.__cache_write();
this.notify('threads');
} catch (e) {
logError(e);
}
}
/**
* Handle a response to telephony.request_conversation(s)
*
* @param {object[]} messages - A list of sms message objects
*/
_handleMessages(messages) {
try {
// If messages is empty there's nothing to do...
if (messages.length === 0) return;
let thread_ids = [];
// Perform some modification of the messages
for (let i = 0, len = messages.length; i < len; i++) {
let message = messages[i];
// COERCION: thread_id's to strings
message.thread_id = `${message.thread_id}`;
thread_ids.push (message.thread_id);
// TODO: Remove bogus `insert-address-token` entries
let a = message.addresses.length;
while (a--) {
if (message.addresses[a].address === undefined ||
message.addresses[a].address === 'insert-address-token')
message.addresses.splice(a, 1);
}
}
// If there's multiple thread_id's it's a summary of threads
if (thread_ids.some(id => id !== thread_ids[0])) {
this._handleDigest(messages, thread_ids);
// Otherwise this is single thread or new message
} else {
this._handleThread(messages);
}
} catch (e) {
logError(e);
}
}
/**
* Request a list of messages from a single thread.
*
* @param {Number} thread_id - The id of the thread to request
*/
requestConversation(thread_id) {
this.device.sendPacket({
type: 'kdeconnect.sms.request_conversation',
body: {
threadID: thread_id
}
});
}
/**
* Request a list of the last message in each unarchived thread.
*/
requestConversations() {
this.device.sendPacket({
type: 'kdeconnect.sms.request_conversations'
});
}
/**
* A notification action for replying to SMS messages (or missed calls).
*
* @param {string} hint - Could be either a contact name or phone number
*/
replySms(hint) {
this.window.present();
// FIXME: causes problems now that non-numeric addresses are allowed
//this.window.address = hint.toPhoneNumber();
}
/**
* Send an SMS message
*
* @param {string} phoneNumber - The phone number to send the message to
* @param {string} messageBody - The message to send
*/
sendSms(phoneNumber, messageBody) {
this.sendMessage([{address: phoneNumber}], messageBody, 1, true);
}
/**
* Send a message
*
* @param {Array of Address} addresses - A list of address objects
* @param {string} messageBody - The message text
* @param {number} [event] - An event bitmask
* @param {boolean} [forceSms] - Whether to force SMS
* @param {number} [subId] - The SIM card to use
*/
sendMessage(addresses, messageBody, event = 1, forceSms = false, subId = undefined) {
// TODO: waiting on support in kdeconnect-android
// if (this._version === 1) {
this.device.sendPacket({
type: 'kdeconnect.sms.request',
body: {
sendSms: true,
phoneNumber: addresses[0].address,
messageBody: messageBody
}
});
// } else if (this._version == 2) {
// this.device.sendPacket({
// type: 'kdeconnect.sms.request',
// body: {
// version: 2,
// addresses: addresses,
// messageBody: messageBody,
// forceSms: forceSms,
// sub_id: subId
// }
// });
// }
}
/**
* Share a text content by SMS message. This is used by the WebExtension to
* share URLs from the browser, but could be used to initiate sharing of any
* text content.
*
* @param {string} url - The link to be shared
*/
shareSms(url) {
// Legacy Mode
if (this.settings.get_boolean('legacy-sms')) {
let window = this.window;
window.present();
window.setMessage(url);
// If there are active threads, show the chooser dialog
} else if (Object.values(this.threads).length > 0) {
let window = new Messaging.ConversationChooser({
application: this.service,
device: this.device,
message: url,
plugin: this
});
window.present();
// Otherwise show the window and wait for a contact to be chosen
} else {
this.window.present();
this.window.setMessage(url, true);
}
}
/**
* Open and present the messaging window
*/
sms() {
this.window.present();
}
/**
* This is the sms: URI scheme handler
*
* @param {string} uri - The URI the handle (sms:|sms://|sms:///)
*/
uriSms(uri) {
try {
uri = new URI.SmsURI(uri);
// Lookup contacts
let addresses = uri.recipients.map(number => {
return {address: number.toPhoneNumber()};
});
let contacts = this.device.contacts.lookupAddresses(addresses);
// Present the window and show the conversation
let window = this.window;
window.present();
window.setContacts(contacts);
// Set the outgoing message if the uri has a body variable
if (uri.body) {
window.setMessage(uri.body);
}
} catch (e) {
logError(e, `${this.device.name}: "${uri}"`);
}
}
addressesIncludesAddress(addresses, addressObj) {
let number = addressObj.address.toPhoneNumber();
for (let taddressObj of addresses) {
let tnumber = taddressObj.address.toPhoneNumber();
if (number.endsWith(tnumber) || tnumber.endsWith(number)) {
return true;
}
}
return false;
}
_threadHasAddress(thread, addressObj) {
let number = addressObj.address.toPhoneNumber();
for (let taddressObj of thread[0].addresses) {
let tnumber = taddressObj.address.toPhoneNumber();
if (number.endsWith(tnumber) || tnumber.endsWith(number)) {
return true;
}
}
return false;
}
/**
* Try to find a thread_id in @smsPlugin for @addresses.
*
* @param {Array of Object} - a list of address objects
*/
getThreadIdForAddresses(addresses) {
let threads = Object.values(this.threads);
for (let thread of threads) {
if (addresses.length !== thread[0].addresses.length) continue;
if (addresses.every(addressObj => this._threadHasAddress(thread, addressObj))) {
return thread[0].thread_id;
}
}
return null;
}
destroy() {
if (this._window) {
this._window.destroy();
}
super.destroy();
}
});

View File

@@ -0,0 +1,198 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('System Volume'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SystemVolume',
incomingCapabilities: ['kdeconnect.systemvolume.request'],
outgoingCapabilities: ['kdeconnect.systemvolume'],
actions: {}
};
/**
* SystemVolume Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/systemvolume
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SystemvolumePlugin/
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectSystemVolumePlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'systemvolume');
try {
// Connect to the mixer
this._pulseaudio = this.service.components.get('pulseaudio');
this._streamChangedId = this._pulseaudio.connect(
'stream-changed',
this._sendSink.bind(this)
);
this._outputAddedId = this._pulseaudio.connect(
'output-added',
this._sendSinkList.bind(this)
);
this._outputRemovedId = this._pulseaudio.connect(
'output-removed',
this._sendSinkList.bind(this)
);
// Cache stream properties
this._cache = new WeakMap();
} catch (e) {
this.destroy();
e.name = 'GvcError';
throw e;
}
}
handlePacket(packet) {
switch (true) {
case packet.body.hasOwnProperty('requestSinks'):
this._sendSinkList();
break;
case packet.body.hasOwnProperty('name'):
this._changeSink(packet);
break;
}
}
connected() {
super.connected();
this._sendSinkList();
}
/**
* Handle a request to change an output
*/
_changeSink(packet) {
let stream;
for (let sink of this._pulseaudio.get_sinks()) {
if (sink.name === packet.body.name) {
stream = sink;
break;
}
}
// No sink with the given name
if (stream === undefined) {
this._sendSinkList();
return;
}
// Get a cache and store volume and mute states if changed
let cache = this._cache.get(stream) || {};
if (packet.body.hasOwnProperty('muted')) {
cache.muted = packet.body.muted;
this._cache.set(stream, cache);
stream.change_is_muted(packet.body.muted);
}
if (packet.body.hasOwnProperty('volume')) {
cache.volume = packet.body.volume;
this._cache.set(stream, cache);
stream.volume = packet.body.volume;
stream.push_volume();
}
}
/**
* Update the cache for @stream
*
* @param {Gvc.MixerStream} stream - The stream to cache
* @return {object} - The updated cache object
*/
_updateCache(stream) {
let state = {
name: stream.name,
description: stream.display_name,
muted: stream.is_muted,
volume: stream.volume,
maxVolume: this._pulseaudio.get_vol_max_norm()
};
this._cache.set(stream, state);
return state;
}
/**
* Send the state of a local sink
*
* @param {Gvc.MixerControl} mixer - The mixer that owns the stream
* @param {Number} id - The Id of the stream that changed
*/
_sendSink(mixer, id) {
// Avoid starving the packet channel when fading
if (this._pulseaudio.fading) {
return;
}
// Check the cache
let stream = this._pulseaudio.lookup_stream_id(id);
let cache = this._cache.get(stream) || {};
// If the port has changed we have to send the whole list to update the
// display name
if (!cache.display_name || cache.display_name !== stream.display_name) {
this._sendSinkList();
return;
}
// If only volume and/or mute are set, send a single update
if (cache.volume !== stream.volume || cache.muted !== stream.is_muted) {
// Update the cache
let state = this._updateCache(stream);
// Send the stream update
this.device.sendPacket({
type: 'kdeconnect.systemvolume',
body: state
});
}
}
/**
* Send a list of local sinks
*/
_sendSinkList() {
let sinkList = this._pulseaudio.get_sinks().map(sink => {
return this._updateCache(sink);
});
// Send the sinkList
this.device.sendPacket({
type: 'kdeconnect.systemvolume',
body: {
sinkList: sinkList
}
});
}
destroy() {
try {
this._pulseaudio.disconnect(this._streamChangedId);
this._pulseaudio.disconnect(this._outputAddedId);
this._pulseaudio.disconnect(this._outputRemovedId);
} catch (e) {
debug(e, this.device.name);
}
super.destroy();
}
});

View File

@@ -0,0 +1,296 @@
'use strict';
const GdkPixbuf = imports.gi.GdkPixbuf;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const PluginsBase = imports.service.plugins.base;
const TelephonyUI = imports.service.ui.telephony;
var Metadata = {
label: _('Telephony'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony',
incomingCapabilities: ['kdeconnect.telephony'],
outgoingCapabilities: [
'kdeconnect.telephony.request',
'kdeconnect.telephony.request_mute'
],
actions: {
legacyReply: {
// TRANSLATORS: Respond to an incoming call via SMS
label: _('Reply SMS'),
icon_name: 'sms-symbolic',
parameter_type: new GLib.VariantType('a{sv}'),
incoming: ['kdeconnect.telephony'],
outgoing: ['kdeconnect.sms.request']
},
muteCall: {
// TRANSLATORS: Silence the actively ringing call
label: _('Mute Call'),
icon_name: 'audio-volume-muted-symbolic',
parameter_type: null,
incoming: ['kdeconnect.telephony'],
outgoing: ['kdeconnect.telephony.request_mute']
}
}
};
/**
* Telephony Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/telephony
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/TelephonyPlugin
*/
var Plugin = GObject.registerClass({
GTypeName: 'GSConnectTelephonyPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'telephony');
}
get legacy_sms() {
// We have to do this lookup each time, because if we hold a reference
// to the plugin we don't know if it's disabled
let sms = this.device._plugins.get('sms');
return (sms && sms.settings.get_boolean('legacy-sms'));
}
async handlePacket(packet) {
try {
// This is the end of a 'ringing' or 'talking' event
if (packet.body.isCancel) {
let sender = packet.body.contactName || packet.body.phoneNumber;
this.device.hideNotification(`${packet.body.event}|${sender}`);
this._restoreMediaState();
// Only handle 'ringing' or 'talking' events, leave the notification
// plugin to handle 'missedCall' and 'sms' since they're repliable
} else if (['ringing', 'talking'].includes(packet.body.event)) {
this._handleEvent(packet);
// Legacy messaging support
} else if (packet.body.event === 'sms' && this.legacy_sms) {
this._handleLegacyMessage(packet);
}
} catch (e) {
logError(e);
}
}
/**
* Change volume, microphone and media player state in response to an
* incoming or answered call.
*
* @param {String} eventType - 'ringing' or 'talking'
*/
_setMediaState(eventType) {
// Mixer Volume
let pulseaudio = this.service.components.get('pulseaudio');
if (pulseaudio) {
switch (this.settings.get_string(`${eventType}-volume`)) {
case 'restore':
pulseaudio.restore();
break;
case 'lower':
pulseaudio.lowerVolume();
break;
case 'mute':
pulseaudio.muteVolume();
break;
}
if (eventType === 'talking' && this.settings.get_boolean('talking-microphone')) {
pulseaudio.muteMicrophone();
}
}
// Media Playback
let mpris = this.service.components.get('mpris');
if (mpris && this.settings.get_boolean(`${eventType}-pause`)) {
mpris.pauseAll();
}
}
/**
* Restore volume, microphone and media player state (if changed), making
* sure to unpause before raising volume.
*/
_restoreMediaState() {
// Media Playback
let mpris = this.service.components.get('mpris');
if (mpris) {
mpris.unpauseAll();
}
// Mixer Volume
let pulseaudio = this.service.components.get('pulseaudio');
if (pulseaudio) {
pulseaudio.restore();
}
}
/**
* Load a Gdk.Pixbuf from base64 encoded data
*
* @param {string} data - Base64 encoded JPEG data
*/
_getThumbnailPixbuf(data) {
let loader;
try {
data = GLib.base64_decode(data);
loader = new GdkPixbuf.PixbufLoader();
loader.write(data);
loader.close();
} catch (e) {
debug(e);
}
return loader.get_pixbuf();
}
/**
* Show a local notification, possibly with actions
*
* @param {object} packet - A telephony packet for this event
*/
_handleEvent(packet) {
let body;
let buttons = [];
let icon = new Gio.ThemedIcon({name: 'call-start-symbolic'});
let priority = Gio.NotificationPriority.NORMAL;
// Ensure we have a sender
// TRANSLATORS: No name or phone number
let sender = _('Unknown Contact');
if (packet.body.contactName) {
sender = packet.body.contactName;
} else if (packet.body.phoneNumber) {
sender = packet.body.phoneNumber;
}
// If there's a photo, use it as the notification icon
if (packet.body.phoneThumbnail) {
icon = this._getThumbnailPixbuf(packet.body.phoneThumbnail);
}
if (packet.body.event === 'ringing') {
this._setMediaState('ringing');
// TRANSLATORS: The phone is ringing
body = _('Incoming call');
buttons = [{
action: 'muteCall',
// TRANSLATORS: Silence the actively ringing call
label: _('Mute'),
parameter: null
}];
priority = Gio.NotificationPriority.URGENT;
}
if (packet.body.event === 'talking') {
this.device.hideNotification(`ringing|${sender}`);
this._setMediaState('talking');
// TRANSLATORS: A phone call is active
body = _('Ongoing call');
}
this.device.showNotification({
id: `${packet.body.event}|${sender}`,
title: sender,
body: body,
icon: icon,
priority: priority,
buttons: buttons
});
}
_handleLegacyMessage(packet) {
let action = null;
let icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
// Ensure we have a sender
// TRANSLATORS: No name or phone number
let sender = _('Unknown Contact');
if (packet.body.contactName) {
sender = packet.body.contactName;
} else if (packet.body.phoneNumber) {
sender = packet.body.phoneNumber;
}
// If there's a photo, use it as the notification icon
if (packet.body.phoneThumbnail) {
icon = this._getThumbnailPixbuf(packet.body.phoneThumbnail);
}
// If there's a phone number we can make this repliable
if (packet.body.phoneNumber) {
action = {
name: 'legacyReply',
parameter: GLib.Variant.full_pack(packet)
};
}
// Show notification
this.device.showNotification({
id: `${packet.body.event}|${sender}`,
title: sender,
body: packet.body.messageBody,
icon: icon,
priority: Gio.NotificationPriority.NORMAL,
action: action
});
}
legacyReply(packet) {
try {
let plugin = this.device._plugins.get('sms');
if (plugin === undefined) {
throw new Error('SMS Plugin is disabled');
}
let dialog = new TelephonyUI.LegacyMessagingDialog({
device: this.device,
message: {
date: packet.id,
addresses: [{address: packet.body.phoneNumber}],
body: packet.body.messageBody,
sender: packet.body.contactName || _('Unknown Contact'),
type: 1 // MessageBox.INBOX
},
plugin: plugin
});
dialog.present();
} catch (e) {
logError(e);
}
}
/**
* Silence an incoming call
*/
muteCall() {
this.device.sendPacket({
type: 'kdeconnect.telephony.request_mute',
body: {}
});
this._restoreMediaState();
}
});

View File

@@ -0,0 +1,125 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
/**
* Creates a GTlsCertificate from the PEM-encoded data in @cert_path and
* @key_path. If either are missing a new pair will be generated.
*
* Additionally, the private key will be added using ssh-add to allow sftp
* connections using Gio.
*
* @param {string} certPath - Absolute path to a x509 certificate in PEM format
* @param {string} keyPath - Absolute path to a private key in PEM format
* @param {string} commonName - A unique common name for the certificate
*
* See: https://github.com/KDE/kdeconnect-kde/blob/master/core/kdeconnectconfig.cpp#L119
*/
Gio.TlsCertificate.new_for_paths = function (certPath, keyPath, commonName = null) {
// Check if the certificate/key pair already exists
let certExists = GLib.file_test(certPath, GLib.FileTest.EXISTS);
let keyExists = GLib.file_test(keyPath, GLib.FileTest.EXISTS);
// Create a new certificate and private key if necessary
if (!certExists || !keyExists) {
// If we weren't passed a common name, generate a random one
if (!commonName) {
commonName = GLib.uuid_string_random();
}
let proc = new Gio.Subprocess({
argv: [
gsconnect.metadata.bin.openssl, 'req',
'-new', '-x509', '-sha256',
'-out', certPath,
'-newkey', 'rsa:4096', '-nodes',
'-keyout', keyPath,
'-days', '3650',
'-subj', `/O=andyholmes.github.io/OU=GSConnect/CN=${commonName}`
],
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
Gio.SubprocessFlags.STDERR_SILENCE)
});
proc.init(null);
proc.wait_check(null);
}
return Gio.TlsCertificate.new_from_files(certPath, keyPath);
};
Object.defineProperties(Gio.TlsCertificate.prototype, {
/**
* Compute a SHA1 fingerprint of the certificate.
* See: https://gitlab.gnome.org/GNOME/glib/issues/1290
*
* @return {string} - A SHA1 fingerprint of the certificate.
*/
'fingerprint': {
value: function() {
if (!this.__fingerprint) {
let proc = new Gio.Subprocess({
argv: [gsconnect.metadata.bin.openssl, 'x509', '-noout', '-fingerprint', '-sha1', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE
});
proc.init(null);
let stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
this.__fingerprint = /[a-zA-Z0-9:]{59}/.exec(stdout)[0];
proc.wait_check(null);
}
return this.__fingerprint;
},
enumerable: false
},
/**
* The common name of the certificate.
*/
'common_name': {
get: function() {
if (!this.__common_name) {
let proc = new Gio.Subprocess({
argv: [gsconnect.metadata.bin.openssl, 'x509', '-noout', '-subject', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE
});
proc.init(null);
let stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
this.__common_name = /(?:cn|CN) ?= ?([^,\n]*)/.exec(stdout)[1];
proc.wait_check(null);
}
return this.__common_name;
},
enumerable: true
},
/**
* The common name of the certificate.
*/
'certificate_der': {
get: function() {
if (!this.__certificate_der) {
let proc = new Gio.Subprocess({
argv: [gsconnect.metadata.bin.openssl, 'x509', '-outform', 'der', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE
});
proc.init(null);
let stdout = proc.communicate(new GLib.Bytes(this.certificate_pem), null)[1];
this.__certificate_der = stdout.toArray();
proc.wait_check(null);
}
return this.__certificate_der;
},
enumerable: true
}
});

View File

@@ -0,0 +1,516 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
/**
* Packet
*
* The packet class is a simple Object-derived class. It only exists to offer
* conveniences for coercing to a string writable to a channel and constructing
* from Strings and Objects. In future, it could probably be optimized to avoid
* excessive shape-trees since it's the most common object in the protocol.
*/
var Packet = class Packet {
constructor(data = null) {
this.id = 0;
this.type = undefined;
this.body = {};
if (typeof data === 'string') {
this.fromString(data);
} else {
this.fromObject(data);
}
}
[Symbol.toPrimitive](hint) {
this.id = Date.now();
switch (hint) {
case 'string':
return `${JSON.stringify(this)}\n`;
case 'number':
return `${JSON.stringify(this)}\n`.length;
default:
return true;
}
}
/**
* Update the packet from an Object, using and intermediate call to
* JSON.stringify() to deep-copy the object, avoiding reference entanglement
*
* @param {string} data - An object
*/
fromObject(data) {
try {
let json = JSON.parse(JSON.stringify(data));
Object.assign(this, json);
} catch (e) {
throw Error(`Malformed packet: ${e.message}`);
}
}
/**
* Update the packet from a string of JSON
*
* @param {string} data - A string of text
*/
fromString(data) {
try {
let json = JSON.parse(data);
Object.assign(this, json);
} catch (e) {
throw Error(`Malformed packet: ${e.message}`);
}
}
/**
* Make a deep copy of the packet, using and intermediate call to
* JSON.stringify() to avoid reference entanglement.
*
* @return {Core.Packet} - A new packet
*/
toObject() {
try {
let data = JSON.stringify(this);
return new Packet(data);
} catch (e) {
throw Error(`Malformed packet: ${e.message}`);
}
}
/**
* Serialize the packet as a single line with a terminating new-line (\n)
* character, ready to be written to a channel.
*
* @return {string} - A serialized packet
*/
toString() {
return `${this}`;
}
/**
* Check if the packet has a payload.
*
* @returns (boolean} - %true if @packet has a payload
*/
hasPayload() {
if (!this.hasOwnProperty('payloadSize'))
return false;
if (!this.hasOwnProperty('payloadTransferInfo'))
return false;
return (Object.keys(this.payloadTransferInfo).length > 0);
}
};
/**
* Channel
*
* Channels are essentially wrappers around an I/O stream pair that handle KDE
* Connect identity exchange and either packet or data exchange.
*
* There are effectively two types of channels: packet exchange channels and
* data transfer channels. Both channel types begin by exchanging identity
* packets and then performing whatever encryption or authentication is
* appropriate for the transport protocol.
*
*
* Packet Channels
*
* Packet exchange channels are used to send or receive packets, which are JSON
* objects serialized as single line with a terminating new-line character
* marking the end of the packet. The only packet type allowed to be exchanged
* before authentication is `kdeconnect.identity`. The only packets allowed
* before pairing are `kdeconnect.identity` and `kdeconnect.pair`.
*
*
* Transfer Channels
*
* Data transfer channels are used to send or receive streams of binary data and
* are only possible for paired and authenticated devices. Once the
* identification and authentication has completed, the binary payload is read
* or written and then the channel is closed (unless cancelled first).
*
* These channels are opened when the uploading party sends a packet with two
* extra fields in the top-level of the packet: `payloadSize` (size in bytes)
* and `payloadTransferInfo` which contains protocol specific information such
* as a TCP port. The uploading party then waits for an incoming connection that
* corresponds with the `payloadTransferInfo` field.
*/
var Channel = GObject.registerClass({
GTypeName: 'GSConnectChannel',
Requires: [GObject.Object]
}, class Channel extends GObject.Interface {
get address() {
throw new GObject.NotImplementedError();
}
get backend() {
return this._backend || null;
}
set backend(backend) {
this._backend = backend;
}
get cancellable() {
if (this._cancellable === undefined) {
this._cancellable = new Gio.Cancellable();
}
return this._cancellable;
}
get input_stream() {
if (this._input_stream === undefined) {
if (this._connection instanceof Gio.IOStream) {
return this._connection.get_input_stream();
}
return null;
}
return this._input_stream;
}
set input_stream(stream) {
this._input_stream = stream;
}
get output_queue() {
if (this._output_queue === undefined) {
this._output_queue = [];
}
return this._output_queue;
}
get output_stream() {
if (this._output_stream === undefined) {
if (this._connection instanceof Gio.IOStream) {
return this._connection.get_output_stream();
}
return null;
}
return this._output_stream;
}
set output_stream(stream) {
this._output_stream = stream;
}
get service() {
if (this._service === undefined) {
this._service = Gio.Application.get_default();
}
return this._service;
}
get uuid() {
if (this._uuid === undefined) {
this._uuid = GLib.uuid_string_random();
}
return this._uuid;
}
set uuid(uuid) {
this._uuid = uuid;
}
/**
* Override these to send and receive the identity packet during initial
* connection negotiation.
*/
_receiveIdent(connection) {
throw new GObject.NotImplementedError();
}
_sendIdent(connection) {
throw new GObject.NotImplementedError();
}
accept(connection) {
throw new GObject.NotImplementedError();
}
open(connection) {
throw new GObject.NotImplementedError();
}
/**
* Attach to @device as the default channel used for packet exchange. This
* should connect the channel's Gio.Cancellable to mark the device as
* disconnected, setup the IO streams, start the receive() loop and set the
* device as connected.
*
* @param {Device.Device} device - The device to attach to
*/
attach(device) {
throw new GObject.NotImplementedError();
}
/**
* Close all streams associated with this channel, silencing any errors
*/
close() {
throw new GObject.NotImplementedError();
}
/**
* Receive a packet from the channel and call receivePacket() on the device
*
* @param {Device.Device} device - The device which will handle the packet
*/
receive(device) {
this.input_stream.read_line_async(
GLib.PRIORITY_DEFAULT,
this.cancellable,
(stream, res) => {
try {
let data = stream.read_line_finish_utf8(res)[0];
if (data === null) {
throw new Gio.IOErrorEnum({
message: 'End of stream',
code: Gio.IOErrorEnum.CONNECTION_CLOSED
});
}
// Queue another receive() before handling the packet
this.receive(device);
// Malformed packets aren't fatal
try {
let packet = new Packet(data);
debug(packet, device.name);
device.receivePacket(packet);
} catch (e) {
debug(e, device.name);
}
} catch (e) {
if (!e.code || e.code !== Gio.IOErrorEnum.CANCELLED) {
debug(e, device.name);
}
this.close();
}
}
);
}
/**
* Send a packet to a device
*
* @param {object} packet - An dictionary of packet data
*/
async send(packet) {
let next;
try {
this.output_queue.push(new Packet(packet));
if (!this.__lock) {
this.__lock = true;
while ((next = this.output_queue.shift())) {
await new Promise((resolve, reject) => {
this.output_stream.write_all_async(
next.toString(),
GLib.PRIORITY_DEFAULT,
this.cancellable,
(stream, res) => {
try {
resolve(stream.write_all_finish(res));
} catch (e) {
reject(e);
}
}
);
});
debug(next, this.identity.body.deviceName);
}
this.__lock = false;
}
} catch (e) {
debug(e, this.identity.body.deviceName);
this.close();
}
}
/**
* Override these in subclasses to negotiate payload transfers. `download()`
* and `upload()` should cleanup after themselves and return a success
* boolean.
*
* The default implementation will always report failure, for protocols that
* won't or don't yet support payload transfers.
*/
createTransfer(params) {
throw new GObject.NotImplementedError();
}
async download() {
let result = false;
try {
throw new GObject.NotImplementedError();
} catch (e) {
debug(e, this.identity.body.deviceName);
} finally {
this.close();
}
return result;
}
async upload() {
let result = false;
try {
throw new GObject.NotImplementedError();
} catch (e) {
debug(e, this.identity.body.deviceName);
} finally {
this.close();
}
return result;
}
/**
* Transfer using g_output_stream_splice()
*
* @return {Boolean} - %true on success, %false on failure.
*/
async transfer() {
let result = false;
try {
result = await new Promise((resolve, reject) => {
this.output_stream.splice_async(
this.input_stream,
Gio.OutputStreamSpliceFlags.NONE,
GLib.PRIORITY_DEFAULT,
this.cancellable,
(source, res) => {
try {
if (source.splice_finish(res) < this.size) {
throw new Error('incomplete data');
}
resolve(true);
} catch (e) {
reject(e);
}
}
);
});
} catch (e) {
debug(e, this.device.name);
} finally {
this.close();
}
return result;
}
});
/**
* ChannelService
*/
var ChannelService = GObject.registerClass({
GTypeName: 'GSConnectChannelService',
Requires: [GObject.Object],
Properties: {
'name': GObject.ParamSpec.string(
'name',
'Name',
'The name of the backend',
GObject.ParamFlags.READABLE,
null
)
},
Signals: {
'channel': {
flags: GObject.SignalFlags.RUN_LAST,
param_types: [Channel.$gtype],
return_type: GObject.TYPE_BOOLEAN
},
}
}, class ChannelService extends GObject.Interface {
get name() {
throw new GObject.NotImplementedError();
}
get service() {
if (this._service === undefined) {
this._service = Gio.Application.get_default();
}
return this._service;
}
/**
* Broadcast directly to @address or the whole network if %null
*
* @param {string} [address] - A string address
*/
broadcast(address = null) {
throw new GObject.NotImplementedError();
}
/**
* Emit Core.ChannelService::channel
*
* @param {Core.Channel} channel - The new channel
*/
channel(channel) {
try {
if (!this.emit('channel', channel)) {
channel.close();
}
} catch (e) {
logError(e);
}
}
/**
* Start the channel service. Implementations should throw an error if the
* service fails to meet any of its requirements for opening or accepting
* connections.
*/
start() {
throw new GObject.NotImplementedError();
}
/**
* Stop the channel service.
*/
stop() {
throw new GObject.NotImplementedError();
}
/**
* Destroy the channel service.
*/
destroy() {
}
});

View File

@@ -0,0 +1,998 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Core = imports.service.protocol.core;
/**
* TCP Port Constants
*/
const DEFAULT_PORT = 1716;
const TRANSFER_MIN = 1739;
const TRANSFER_MAX = 1764;
/**
* One-time check for Linux/FreeBSD socket options
*/
var _LINUX_SOCKETS = false;
try {
// This should throw on FreeBSD
// https://github.com/freebsd/freebsd/blob/master/sys/netinet/tcp.h#L159
new Gio.Socket({
family: Gio.SocketFamily.IPV4,
protocol: Gio.SocketProtocol.TCP,
type: Gio.SocketType.STREAM
}).get_option(6, 5);
// Otherwise we can use Linux socket options
_LINUX_SOCKETS = true;
} catch (e) {
_LINUX_SOCKETS = false;
}
/**
* Lan.ChannelService consists of two parts.
*
* The TCP Listener listens on a port and constructs a Channel object from the
* incoming Gio.TcpConnection.
*
* The UDP Listener listens on a port for incoming JSON identity packets which
* include the TCP port for connections, while the IP address is taken from the
* UDP packet itself. We respond to incoming packets by opening a TCP connection
* and broadcast outgoing packets to 255.255.255.255.
*/
var ChannelService = GObject.registerClass({
GTypeName: 'GSConnectLanChannelService',
Implements: [Core.ChannelService],
Properties: {
'name': GObject.ParamSpec.override('name', Core.ChannelService),
'port': GObject.ParamSpec.uint(
'port',
'Port',
'The port used by the service',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
0, GLib.MAXUINT16,
DEFAULT_PORT
)
}
}, class LanChannelService extends GObject.Object {
_init(params) {
super._init(params);
// Track hosts we identify to directly, allowing them to ignore the
// discoverable state of the service.
this._allowed = new Set();
//
this._tcp = null;
this._udp4 = null;
this._udp6 = null;
// Monitor network status
this._networkMonitor = Gio.NetworkMonitor.get_default();
this._networkAvailable = false;
this._networkChangedId = 0;
}
get certificate() {
return this._certificate;
}
get channels() {
if (this._channels === undefined) {
this._channels = new Map();
}
return this._channels;
}
get name() {
return 'lan';
}
get port() {
if (this._port === undefined) {
this._port = DEFAULT_PORT;
}
return this._port;
}
set port(port) {
if (this.port !== port) {
this._port = port;
this.notify('port');
}
}
_onNetworkChanged(monitor, network_available) {
if (this._networkAvailable !== network_available) {
this._networkAvailable = network_available;
this.broadcast();
}
}
_initCertificate() {
let certPath = GLib.build_filenamev([
gsconnect.configdir,
'certificate.pem'
]);
let keyPath = GLib.build_filenamev([
gsconnect.configdir,
'private.pem'
]);
// Ensure a certificate exists with our id as the common name
try {
this._certificate = Gio.TlsCertificate.new_for_paths(
certPath,
keyPath,
this.service.id
);
} catch (e) {
e.name = 'CertificateError';
throw e;
}
// If the service id doesn't match the common name, this is probably a
// certificate from an earlier version and we need to set it now
if (this.service.settings.get_string('id') !== this._certificate.common_name) {
this.service.settings.set_string('id', this._certificate.common_name);
}
}
_initTcpListener() {
this._tcp = new Gio.SocketService();
this._tcp.add_inet_port(this.port, null);
this._tcp.connect('incoming', this._onIncomingChannel.bind(this));
}
async _onIncomingChannel(listener, connection) {
try {
let host = connection.get_remote_address().address.to_string();
// Decide whether we should try to accept this connection
if (!this._allowed.has(host) && !this.service.discoverable) {
connection.close_async(0, null, null);
return;
}
// Create a channel
let channel = new Channel({
backend: this,
certificate: this.certificate,
host: host,
port: DEFAULT_PORT
});
// Accept the connection
await channel.accept(connection);
channel.identity.body.tcpHost = channel.host;
channel.identity.body.tcpPort = DEFAULT_PORT;
this.channel(channel);
} catch (e) {
debug(e);
}
}
_initUdpListener() {
// Default broadcast address
this._udp_address = Gio.InetSocketAddress.new_from_string(
'255.255.255.255',
this.port
);
try {
this._udp6 = new Gio.Socket({
family: Gio.SocketFamily.IPV6,
type: Gio.SocketType.DATAGRAM,
protocol: Gio.SocketProtocol.UDP,
broadcast: true
});
this._udp6.init(null);
// Bind the socket
let inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV6);
let sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
this._udp6.bind(sockAddr, false);
// Input stream
this._udp6_stream = new Gio.DataInputStream({
base_stream: new Gio.UnixInputStream({
fd: this._udp6.fd,
close_fd: false
})
});
// Watch socket for incoming packets
this._udp6_source = this._udp6.create_source(GLib.IOCondition.IN, null);
this._udp6_source.set_callback(this._onIncomingIdentity.bind(this, this._udp6));
this._udp6_source.attach(null);
} catch (e) {
this._udp6 = null;
}
// Our IPv6 socket also supports IPv4; we're all done
if (this._udp6 && this._udp6.speaks_ipv4()) {
this._udp4 = null;
return;
}
try {
this._udp4 = new Gio.Socket({
family: Gio.SocketFamily.IPV4,
type: Gio.SocketType.DATAGRAM,
protocol: Gio.SocketProtocol.UDP,
broadcast: true
});
this._udp4.init(null);
// Bind the socket
let inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV4);
let sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
this._udp4.bind(sockAddr, false);
// Input stream
this._udp4_stream = new Gio.DataInputStream({
base_stream: new Gio.UnixInputStream({
fd: this._udp4.fd,
close_fd: false
})
});
// Watch input socket for incoming packets
this._udp4_source = this._udp4.create_source(GLib.IOCondition.IN, null);
this._udp4_source.set_callback(this._onIncomingIdentity.bind(this, this._udp4));
this._udp4_source.attach(null);
} catch (e) {
this._udp4 = null;
// We failed to get either an IPv4 or IPv6 socket to bind
if (this._udp6 === null) {
e.name = 'LanError';
throw e;
}
}
}
_onIncomingIdentity(socket) {
let host, data, packet;
// Try to peek the remote address
try {
host = socket.receive_message(
[],
Gio.SocketMsgFlags.PEEK,
null
)[1].address.to_string();
} catch (e) {
logError(e);
}
// Whether or not we peeked the address, we need to read the packet
try {
if (socket === this._udp6) {
data = this._udp6_stream.read_line_utf8(null)[0];
} else {
data = this._udp4_stream.read_line_utf8(null)[0];
}
// Only process the packet if we succeeded in peeking the address
if (host !== undefined) {
packet = new Core.Packet(data);
packet.body.tcpHost = host;
this._onIdentity(packet);
}
} catch (e) {
logError(e);
}
return GLib.SOURCE_CONTINUE;
}
async _onIdentity(packet) {
try {
// Bail if the deviceId is missing
if (!packet.body.hasOwnProperty('deviceId')) {
debug(`${packet.body.deviceName}: missing deviceId`);
return;
}
// Silently ignore our own broadcasts
if (packet.body.deviceId === this.service.identity.body.deviceId) {
return;
}
debug(packet);
// Create a new channel
let channel = new Channel({
backend: this,
certificate: this.certificate,
host: packet.body.tcpHost,
port: packet.body.tcpPort,
identity: packet
});
// Check if channel is already open with this address
if (this.channels.has(channel.address)) {
return;
} else {
this.channels.set(channel.address, channel);
}
// Open a TCP connection
let connection = await new Promise((resolve, reject) => {
let address = Gio.InetSocketAddress.new_from_string(
packet.body.tcpHost,
packet.body.tcpPort
);
let client = new Gio.SocketClient({enable_proxy: false});
client.connect_async(address, null, (client, res) => {
try {
resolve(client.connect_finish(res));
} catch (e) {
reject(e);
}
});
});
// Connect the channel and attach it to the device on success
await channel.open(connection);
this.channel(channel);
} catch (e) {
logError(e);
}
}
/**
* Broadcast an identity packet
*
* If @address is not %null it may specify an IPv4 or IPv6 address to send
* the identity packet directly to, otherwise it will be broadcast to the
* default address, 255.255.255.255.
*
* @param {string} [address] - An optional target IPv4 or IPv6 address
*/
broadcast(address = null) {
try {
if (!this._networkAvailable) {
return;
}
// Try to parse strings as <host>:<port>
if (typeof address === 'string') {
let [host, port] = address.split(':');
port = parseInt(port) || DEFAULT_PORT;
address = Gio.InetSocketAddress.new_from_string(host, port);
}
// If we succeed, remember this host
if (address instanceof Gio.InetSocketAddress) {
this._allowed.add(address.address.to_string());
// Broadcast to the network if no address is specified
} else {
debug('Broadcasting to LAN');
address = this._udp_address;
}
// Set the tcpPort before broadcasting
this.service.identity.body.tcpPort = this.port;
if (this._udp6 !== null) {
this._udp6.send_to(address, `${this.service.identity}`, null);
}
if (this._udp4 !== null) {
this._udp4.send_to(address, `${this.service.identity}`, null);
}
} catch (e) {
debug(e, address);
} finally {
this.service.identity.body.tcpPort = undefined;
}
}
start() {
// Ensure a certificate exists
this._initCertificate();
// Start TCP/UDP listeners
if (this._udp4 === null && this._udp6 === null) {
this._initUdpListener();
}
if (this._tcp === null) {
this._initTcpListener();
}
// Monitor network changes
if (this._networkChangedId === 0) {
this._networkAvailable = this._networkMonitor.network_available;
this._networkChangedId = this._networkMonitor.connect(
'network-changed',
this._onNetworkChanged.bind(this)
);
}
}
stop() {
if (this._networkChangedId) {
this._networkMonitor.disconnect(this._networkChangedId);
this._networkChangedId = 0;
this._networkAvailable = false;
}
if (this._tcp !== null) {
this._tcp.stop();
this._tcp.close();
this._tcp = null;
}
if (this._udp6 !== null) {
this._udp6_source.destroy();
this._udp6_stream.close(null);
this._udp6.close();
this._udp6 = null;
}
if (this._udp4 !== null) {
this._udp4_source.destroy();
this._udp4_stream.close(null);
this._udp4.close();
this._udp4 = null;
}
}
destroy() {
try {
this.stop();
} catch (e) {
debug(e);
}
}
});
/**
* Lan Channel
*
* This class essentially just extends Core.Channel to set TCP socket options
* and negotiate TLS encrypted connections.
*/
var Channel = GObject.registerClass({
GTypeName: 'GSConnectLanChannel',
Implements: [Core.Channel]
}, class LanChannel extends GObject.Object {
_init(params) {
super._init();
Object.assign(this, params);
}
get address() {
return `lan://${this.host}:${this.port}`;
}
get certificate() {
return this._certificate || null;
}
set certificate(certificate) {
this._certificate = certificate;
}
get peer_certificate() {
if (this._connection instanceof Gio.TlsConnection) {
return this._connection.get_peer_certificate();
}
return null;
}
get host() {
if (this._host === undefined) {
this._host = null;
}
return this._host;
}
set host(host) {
this._host = host;
}
get port() {
if (this._port === undefined) {
if (this.identity && this.identity.body.tcpPort) {
this._port = this.identity.body.tcpPort;
} else {
return DEFAULT_PORT;
}
}
return this._port;
}
set port(port) {
this._port = port;
}
_initSocket(connection) {
connection.socket.set_keepalive(true);
if (_LINUX_SOCKETS) {
connection.socket.set_option(6, 4, 10); // TCP_KEEPIDLE
connection.socket.set_option(6, 5, 5); // TCP_KEEPINTVL
connection.socket.set_option(6, 6, 3); // TCP_KEEPCNT
} else {
connection.socket.set_option(6, 256, 10); // TCP_KEEPIDLE
connection.socket.set_option(6, 512, 5); // TCP_KEEPINTVL
connection.socket.set_option(6, 1024, 3); // TCP_KEEPCNT
}
return connection;
}
/**
* Handshake Gio.TlsConnection
*/
_handshake(connection) {
return new Promise((resolve, reject) => {
connection.validation_flags = Gio.TlsCertificateFlags.EXPIRED;
connection.authentication_mode = Gio.TlsAuthenticationMode.REQUIRED;
connection.handshake_async(
GLib.PRIORITY_DEFAULT,
this.cancellable,
(connection, res) => {
try {
resolve(connection.handshake_finish(res));
} catch (e) {
reject(e);
}
}
);
});
}
async _authenticate(connection) {
// Standard TLS Handshake
await this._handshake(connection);
// Get a settings object for the device
let settings;
if (this.device) {
settings = this.device.settings;
} else {
let id = this.identity.body.deviceId;
settings = new Gio.Settings({
settings_schema: gsconnect.gschema.lookup(
'org.gnome.Shell.Extensions.GSConnect.Device',
true
),
path: `/org/gnome/shell/extensions/gsconnect/device/${id}/`
});
}
// If we have a certificate for this deviceId, we can verify it
let cert_pem = settings.get_string('certificate-pem');
if (cert_pem !== '') {
let certificate = null;
let verified = false;
try {
certificate = Gio.TlsCertificate.new_from_pem(cert_pem, -1);
verified = certificate.is_same(connection.peer_certificate);
} catch (e) {
logError(e);
}
/* The certificate is incorrect for one of two reasons, but both
* result in us resetting the certificate and unpairing the device.
*
* If the certificate failed to load, it is probably corrupted or
* otherwise invalid. In this case, if we try to continue we will
* certainly crash the Android app.
*
* If the certificate did not match what we expected the obvious
* thing to do is to notify the user, however experience tells us
* this is a result of the user doing something masochistic like
* nuking the Android app data or copying settings between machines.
*/
if (verified === false) {
if (this.device) {
this.device.unpair();
} else {
settings.reset('paired');
settings.reset('certificate-pem');
}
let name = this.identity.body.deviceName;
throw new Error(`${name}: Authentication Failure`);
}
}
return connection;
}
/**
* Wrap the connection in Gio.TlsClientConnection and initiate handshake
*
* @param {Gio.TcpConnection} connection - The unauthenticated connection
* @return {Gio.TlsServerConnection} - The authenticated connection
*/
_clientEncryption(connection) {
connection = Gio.TlsClientConnection.new(
connection,
connection.socket.remote_address
);
connection.set_certificate(this.certificate);
return this._authenticate(connection);
}
/**
* Wrap the connection in Gio.TlsServerConnection and initiate handshake
*
* @param {Gio.TcpConnection} connection - The unauthenticated connection
* @return {Gio.TlsServerConnection} - The authenticated connection
*/
_serverEncryption(connection) {
connection = Gio.TlsServerConnection.new(connection, this.certificate);
// We're the server so we trust-on-first-use and verify after
let _id = connection.connect('accept-certificate', (connection) => {
connection.disconnect(_id);
return true;
});
return this._authenticate(connection);
}
/**
* Read the identity packet from the new connection
*
* @param {Gio.SocketConnection} connection - An unencrypted socket
* @return {Gio.SocketConnection} - The connection after success
*/
_receiveIdent(connection) {
return new Promise((resolve, reject) => {
let stream = new Gio.DataInputStream({
base_stream: connection.input_stream,
close_base_stream: false
});
stream.read_line_async(
GLib.PRIORITY_DEFAULT,
this.cancellable,
(stream, res) => {
try {
let data = stream.read_line_finish_utf8(res)[0];
stream.close(null);
// Store the identity as an object property
this.identity = new Core.Packet(data);
// Reject connections without a deviceId
if (!this.identity.body.deviceId) {
throw new Error('missing deviceId');
}
resolve(connection);
} catch (e) {
reject(e);
}
}
);
});
}
/**
* Write our identity packet to the new connection
*
* @param {Gio.SocketConnection} connection - An unencrypted socket
* @return {Gio.SocketConnection} - The connection after success
*/
_sendIdent(connection) {
return new Promise((resolve, reject) => {
this.service.identity.body.tcpPort = this.backend.port;
connection.output_stream.write_all_async(
`${this.service.identity}`,
GLib.PRIORITY_DEFAULT,
this.cancellable,
(stream, res) => {
try {
this.service.identity.body.tcpPort = undefined;
stream.write_all_finish(res);
resolve(connection);
} catch (e) {
reject(e);
}
}
);
});
}
/**
* Negotiate an incoming connection
*
* @param {Gio.TcpConnection} connection - The incoming connection
*/
async accept(connection) {
try {
debug(`${this.address} (${this.uuid})`);
this.backend.channels.set(this.address, this);
this._connection = this._initSocket(connection);
this._connection = await this._receiveIdent(this._connection);
this._connection = await this._clientEncryption(this._connection);
} catch (e) {
this.close();
return Promise.reject(e);
}
}
/**
* Negotiate an outgoing connection
*
* @param {Gio.SocketConnection} connection - The remote connection
*/
async open(connection) {
try {
debug(`${this.address} (${this.uuid})`);
this.backend.channels.set(this.address, this);
this._connection = this._initSocket(connection);
this._connection = await this._sendIdent(this._connection);
this._connection = await this._serverEncryption(this._connection);
} catch (e) {
this.close();
return Promise.reject(e);
}
}
/**
* Close all streams associated with this channel, silencing any errors
*/
close() {
if (this._closed === undefined) {
debug(`${this.address} (${this.uuid})`);
this._closed = true;
this.backend.channels.delete(this.address);
// Cancel any queued operations
this.cancellable.cancel();
// Close any streams
let streams = [
this.input_stream,
this.output_stream,
this._connection
];
for (let stream of streams) {
try {
stream.close_async(0, null, null);
} catch (e) {
// Silence errors
}
}
}
}
/**
* Attach to @device as the default channel used for packet exchange.
*
* @param {Device.Device} device - The device to attach to
*/
attach(device) {
try {
// Detach any existing channel and avoid an unnecessary disconnect
if (device._channel && device._channel !== this) {
debug(`${device._channel.address} (${device._channel.uuid}) => ${this.address} (${this.uuid})`);
let channel = device._channel;
channel.cancellable.disconnect(channel._id);
channel.close();
this._output_queue = channel._output_queue;
}
// Attach the new channel and parse it's identity
device._channel = this;
this._id = this.cancellable.connect(device._setDisconnected.bind(device));
device._handleIdentity(this.identity);
// Setup streams for packet exchange
this.input_stream = new Gio.DataInputStream({
base_stream: this._connection.input_stream
});
this.output_stream = this._connection.output_stream;
// Start listening for packets
this.receive(device);
device._setConnected();
} catch (e) {
logError(e);
this.close();
}
}
createTransfer(params) {
params = Object.assign(params, {
backend: this.backend,
certificate: this.certificate,
host: this.host
});
return new Transfer(params);
}
});
/**
* Lan Transfer
*/
var Transfer = GObject.registerClass({
GTypeName: 'GSConnectLanTransfer'
}, class Transfer extends Channel {
/**
* @param {object} params - Transfer parameters
* @param {Device.Device} params.device - The device that owns this transfer
* @param {Gio.InputStream} params.input_stream - The input stream (read)
* @param {Gio.OutputStream} params.output_stream - The output stream (write)
* @param {number} params.size - The size of the transfer in bytes
*/
_init(params) {
super._init(params);
// The device tracks transfers it owns so they can be closed from the
// notification action.
this.device._transfers.set(this.uuid, this);
}
/**
* Override to untrack the transfer UUID
*/
close() {
this.device._transfers.delete(this.uuid);
super.close();
}
/**
* Connect to @port and read from the remote output stream into the local
* input stream.
*
* When finished the channel and local input stream will be closed whether
* or not the transfer succeeds.
*
* @return {boolean} - %true on success or %false on fail
*/
async download() {
let result = false;
try {
this._connection = await new Promise((resolve, reject) => {
// Connect
let client = new Gio.SocketClient({enable_proxy: false});
// Use the address from GSettings with @port
let address = Gio.InetSocketAddress.new_from_string(
this.host,
this.port
);
client.connect_async(address, null, (client, res) => {
try {
resolve(client.connect_finish(res));
} catch (e) {
reject(e);
}
});
});
this._connection = await this._initSocket(this._connection);
this._connection = await this._clientEncryption(this._connection);
this.input_stream = this._connection.get_input_stream();
// Start the transfer
result = await this.transfer();
} catch (e) {
logError(e, this.device.name);
} finally {
this.close();
}
return result;
}
/**
* Start listening on the first available port for an incoming connection,
* then send @packet with the payload transfer info. When the connection is
* accepted write to the remote input stream from the local output stream.
*
* When finished the channel and local output stream will be closed whether
* or not the transfer succeeds.
*
* @param {Core.Packet} packet - The packet describing the transfer
* @return {boolean} - %true on success or %false on fail
*/
async upload(packet) {
let port = TRANSFER_MIN;
let result = false;
try {
// Start listening on the first available port between 1739-1764
let listener = new Gio.SocketListener();
while (port <= TRANSFER_MAX) {
try {
listener.add_inet_port(port, null);
this._port = port;
break;
} catch (e) {
if (port < TRANSFER_MAX) {
port++;
continue;
} else {
throw e;
}
}
}
// Await the incoming connection
let connection = new Promise((resolve, reject) => {
listener.accept_async(
this.cancellable,
(listener, res, source_object) => {
try {
resolve(listener.accept_finish(res)[0]);
} catch (e) {
reject(e);
}
}
);
});
// Notify the device we're ready
packet.body.payloadHash = this.checksum;
packet.payloadSize = this.size;
packet.payloadTransferInfo = {port: port};
this.device.sendPacket(packet);
// Accept the connection and configure the channel
this._connection = await connection;
this._connection = await this._initSocket(this._connection);
this._connection = await this._serverEncryption(this._connection);
this.output_stream = this._connection.get_output_stream();
// Start the transfer
result = await this.transfer();
} catch (e) {
logError(e, this.device.name);
} finally {
this.close();
}
return result;
}
});

View File

@@ -0,0 +1,49 @@
'use strict';
const Gdk = imports.gi.Gdk;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Gtk = imports.gi.Gtk;
/**
* Window State
*/
Gtk.Window.prototype.restoreGeometry = function(context = 'default') {
this._windowState = new Gio.Settings({
settings_schema: gsconnect.gschema.lookup(
'org.gnome.Shell.Extensions.GSConnect.WindowState',
true
),
path: `/org/gnome/shell/extensions/gsconnect/${context}/`
});
// Size
let [width, height] = this._windowState.get_value('window-size').deepUnpack();
if (width && height) {
this.set_default_size(width, height);
}
// Maximized State
if (this._windowState.get_boolean('window-maximized')) {
this.maximize();
}
};
Gtk.Window.prototype.saveGeometry = function() {
let state = this.get_window().get_state();
// Maximized State
let maximized = (state & Gdk.WindowState.MAXIMIZED);
this._windowState.set_boolean('window-maximized', maximized);
// Leave the size at the value before maximizing
if (maximized || (state & Gdk.WindowState.FULLSCREEN))
return;
// Size
let size = this.get_size();
this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
};

View File

@@ -0,0 +1,619 @@
'use strict';
const Gdk = imports.gi.Gdk;
const GdkPixbuf = imports.gi.GdkPixbuf;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
/**
* Return a random color
*
* @param {*} [salt] - If not %null, will be used as salt for generating a color
* @param {Number} alpha - A value in the [0...1] range for the alpha channel
* @return {Gdk.RGBA} - A new Gdk.RGBA object generated from the input
*/
function randomRGBA(salt = null, alpha = 1.0) {
let red, green, blue;
if (salt !== null) {
let hash = new GLib.Variant('s', `${salt}`).hash();
red = ((hash & 0xFF0000) >> 16) / 255;
green = ((hash & 0x00FF00) >> 8) / 255;
blue = (hash & 0x0000FF) / 255;
} else {
red = Math.random();
green = Math.random();
blue = Math.random();
}
return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
}
/**
* Get the relative luminance of a RGB set
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
*
* @param {Number} r - A number in the [0.0, 1.0] range for the red value
* @param {Number} g - A number in the [0.0, 1.0] range for the green value
* @param {Number} b - A number in the [0.0, 1.0] range for the blue value
* @return {Number} - ...
*/
function relativeLuminance(rgba) {
let {red, green, blue} = rgba;
let R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
let G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
let B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
/**
* Get a Gdk.RGBA contrasted for the input
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Gdk.RGBA} - A Gdk.RGBA object for the background color
* @return {Gdk.RGBA} - A Gdk.RGBA object for the foreground color
*/
function getFgRGBA(rgba) {
let bgLuminance = relativeLuminance(rgba);
let lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
let darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
let value = (darkContrast > lightContrast) ? 0.06 : 0.94;
return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
}
/**
* Get Gdk.Pixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
* sends. This function is synchronous.
*
* @param {string} path - A local file path
*/
function getPixbufForPath(path, size = null, scale) {
let data, loader, pixbuf;
// Catch missing avatar files
try {
data = GLib.file_get_contents(path)[1];
} catch (e) {
debug(e, path);
return undefined;
}
// Consider errors from partially corrupt JPEGs to be warnings
try {
loader = new GdkPixbuf.PixbufLoader();
loader.write(data);
loader.close();
} catch (e) {
debug(e, path);
}
pixbuf = loader.get_pixbuf();
// Scale to monitor
size = size * scale;
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
}
function getPixbufForIcon(name, size, scale, bgColor) {
let color = getFgRGBA(bgColor);
let theme = Gtk.IconTheme.get_default();
let info = theme.lookup_icon_for_scale(
name,
size,
scale,
Gtk.IconLookupFlags.FORCE_SYMBOLIC
);
return info.load_symbolic(color, null, null, null)[0];
}
/**
* Return a localized string for a phone number and type
* See: http://www.ietf.org/rfc/rfc2426.txt
*
* @param {string} number - A phone number and RFC2426 phone number type
* @return {string} - A string like '555-5555・Mobile'
*/
function getNumberLabel(number) {
if (!number.type) return _('%s・Other').format(number.value);
switch (true) {
case number.type.includes('fax'):
// TRANSLATORS: A fax number
return _('%s・Fax').format(number.value);
case number.type.includes('work'):
// TRANSLATORS: A work phone number
return _('%s・Work'.format(number.value));
case number.type.includes('cell'):
// TRANSLATORS: A mobile or cellular phone number
return _('%s・Mobile').format(number.value);
case number.type.includes('home'):
// TRANSLATORS: A home phone number
return _('%s・Home').format(number.value);
default:
// TRANSLATORS: All other phone number types
return _('%s・Other').format(number.value);
}
}
/**
* Get a display number from @contact for @address.
*
* @param {object} contact - A contact object
* @param {string} address - A phone number
*/
function getDisplayNumber(contact, address) {
let number = address.toPhoneNumber();
for (let contactNumber of contact.numbers) {
let cnumber = contactNumber.value.toPhoneNumber();
if (number.endsWith(cnumber) || cnumber.endsWith(number)) {
return GLib.markup_escape_text(contactNumber.value, -1);
}
}
return GLib.markup_escape_text(address, -1);
}
/**
* Contact Avatar
*/
const AvatarCache = new WeakMap();
var Avatar = GObject.registerClass({
GTypeName: 'GSConnectContactAvatar'
}, class Avatar extends Gtk.DrawingArea {
_init(contact = null) {
super._init({
height_request: 32,
width_request: 32,
valign: Gtk.Align.CENTER,
visible: true
});
this.contact = contact;
}
get rgba() {
if (this._rgba === undefined) {
if (this.contact) {
this._rgba = randomRGBA(this.contact.name);
} else {
this._rgba = randomRGBA(GLib.uuid_string_random());
}
}
return this._rgba;
}
get contact() {
if (this._contact === undefined) {
this._contact = null;
}
return this._contact;
}
set contact(contact) {
if (this.contact !== contact) {
this._contact = contact;
this._surface = undefined;
this._rgba = undefined;
this._offset = 0;
}
}
_loadSurface() {
// Get the monitor scale
let display = Gdk.Display.get_default();
let monitor = display.get_monitor_at_window(this.get_window());
let scale = monitor.get_scale_factor();
// If there's a contact with an avatar, try to load it
if (this.contact && this.contact.avatar) {
// Check the cache
this._surface = AvatarCache.get(this.contact);
// Try loading the pixbuf
if (!this._surface) {
let pixbuf = getPixbufForPath(
this.contact.avatar,
this.width_request,
scale
);
if (pixbuf) {
this._surface = Gdk.cairo_surface_create_from_pixbuf(
pixbuf,
0,
this.get_window()
);
AvatarCache.set(this.contact, this._surface);
}
}
}
// If we still don't have a surface, load a fallback
if (!this._surface) {
let iconName;
// If we were given a contact, it's direct message
if (this.contact) {
iconName = 'avatar-default-symbolic';
// Otherwise it's a group message
} else {
iconName = 'group-avatar-symbolic';
}
this._offset = (this.width_request - 24) / 2;
// Load the fallback
let pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);
this._surface = Gdk.cairo_surface_create_from_pixbuf(
pixbuf,
0,
this.get_window()
);
}
}
vfunc_draw(cr) {
if (!this._surface) {
this._loadSurface();
}
// Clip to a circle
let rad = this.width_request / 2;
cr.arc(rad, rad, rad, 0, 2 * Math.PI);
cr.clipPreserve();
// Fill the background if the the surface is offset
if (this._offset > 0) {
Gdk.cairo_set_source_rgba(cr, this.rgba);
cr.fill();
}
// Draw the avatar/icon
cr.setSourceSurface(this._surface, this._offset, this._offset);
cr.paint();
cr.$dispose();
return Gdk.EVENT_PROPAGATE;
}
});
var ContactChooser = GObject.registerClass({
GTypeName: 'GSConnectContactChooser',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
),
'store': GObject.ParamSpec.object(
'store',
'Store',
'The contacts store',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
GObject.Object
)
},
Signals: {
'number-selected': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_STRING]
}
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
Children: ['entry', 'list', 'scrolled']
}, class ContactChooser extends Gtk.Grid {
_init(params) {
super._init(params);
// Setup the contact list
this.list._entry = this.entry.text;
this.list.set_filter_func(this._filter);
this.list.set_sort_func(this._sort);
// Make sure we're using the correct contacts store
this.device.bind_property(
'contacts',
this,
'store',
GObject.BindingFlags.SYNC_CREATE
);
// Cleanup on ::destroy
this.connect('destroy', this._onDestroy);
}
get store() {
if (this._store === undefined) {
this._store = null;
}
return this._store;
}
set store(store) {
// Do nothing if the store hasn't changed
if (this.store === store) return;
// Unbind the old store
if (this._store) {
// Disconnect from the store
this._store.disconnect(this._contactAddedId);
this._store.disconnect(this._contactRemovedId);
this._store.disconnect(this._contactChangedId);
// Clear the contact list
let rows = this.list.get_children();
for (let i = 0, len = rows.length; i < len; i++) {
rows[i].destroy();
// HACK: temporary mitigator for mysterious GtkListBox leak
imports.system.gc();
}
}
// Set the store
this._store = store;
// Bind the new store
if (this._store) {
// Connect to the new store
this._contactAddedId = store.connect(
'contact-added',
this._onContactAdded.bind(this)
);
this._contactRemovedId = store.connect(
'contact-removed',
this._onContactRemoved.bind(this)
);
this._contactChangedId = store.connect(
'contact-changed',
this._onContactChanged.bind(this)
);
// Populate the list
this._populate();
}
}
/**
* ContactStore Callbacks
*/
_onContactAdded(store, id) {
let contact = this.store.get_contact(id);
this._addContact(contact);
}
_onContactRemoved(store, id) {
let rows = this.list.get_children();
for (let i = 0, len = rows.length; i < len; i++) {
let row = rows[i];
if (row.contact.id === id) {
row.destroy();
// HACK: temporary mitigator for mysterious GtkListBox leak
imports.system.gc();
}
}
}
_onContactChanged(store, id) {
this._onContactRemoved(store, id);
this._onContactAdded(store, id);
}
_onDestroy(chooser) {
chooser.store = null;
}
_onSearchChanged(entry) {
this.list._entry = entry.text;
let dynamic = this.list.get_row_at_index(0);
// If the entry contains string with 2 or more digits...
if (entry.text.replace(/\D/g, '').length >= 2) {
// ...ensure we have a dynamic contact for it
if (!dynamic || !dynamic.__tmp) {
dynamic = this._addContact({
// TRANSLATORS: A phone number (eg. "Send to 555-5555")
name: _('Send to %s').format(entry.text),
numbers: [{type: 'unknown', value: entry.text}]
});
dynamic.__tmp = true;
// ...or if we already do, then update it
} else {
// Update contact object
dynamic.contact.name = entry.text;
dynamic.contact.numbers[0].value = entry.text;
// Update UI
let grid = dynamic.get_child();
let nameLabel = grid.get_child_at(1, 0);
nameLabel.label = _('Send to %s').format(entry.text);
let numLabel = grid.get_child_at(1, 1);
numLabel.label = getNumberLabel(dynamic.contact.numbers[0]);
}
// ...otherwise remove any dynamic contact that's been created
} else if (dynamic && dynamic.__tmp) {
dynamic.destroy();
}
this.list.invalidate_filter();
this.list.invalidate_sort();
}
// GtkListBox::row-selected
_onNumberSelected(box, row) {
if (row === null) return;
// Emit the number
let address = row.number.value;
this.emit('number-selected', address);
// Reset the contact list
this.entry.text = '';
this.list.select_row(null);
this.scrolled.vadjustment.value = 0;
}
_filter(row) {
// Dynamic contact always shown
if (row.__tmp) return true;
let query = row.get_parent()._entry;
let queryName = query.toLocaleLowerCase();
let queryNumber = query.toPhoneNumber();
// Show contact if text is substring of name
if (row.contact.name.toLocaleLowerCase().includes(queryName)) {
return true;
// Show contact if text is substring of number
} else if (queryNumber.length) {
for (let number of row.contact.numbers) {
if (number.value.toPhoneNumber().includes(queryNumber)) {
return true;
}
}
// Query is effectively empty
} else if (/^0+/.test(query)) {
return true;
}
return false;
}
_sort(row1, row2) {
if (row1.__tmp) {
return -1;
} else if (row2.__tmp) {
return 1;
}
return row1.contact.name.localeCompare(row2.contact.name);
}
_populate() {
// Add each contact
let contacts = this.store.contacts;
for (let i = 0, len = contacts.length; i < len; i++) {
this._addContact(contacts[i]);
}
}
_addContactNumber(contact, index) {
let row = new Gtk.ListBoxRow({
activatable: true,
selectable: true,
visible: true
});
row.contact = contact;
row.number = contact.numbers[index];
this.list.add(row);
let grid = new Gtk.Grid({
margin: 6,
column_spacing: 6,
visible: true
});
row.add(grid);
if (index === 0) {
let avatar = new Avatar(contact);
avatar.valign = Gtk.Align.CENTER;
grid.attach(avatar, 0, 0, 1, 2);
let nameLabel = new Gtk.Label({
label: GLib.markup_escape_text(contact.name, -1),
halign: Gtk.Align.START,
hexpand: true,
visible: true
});
grid.attach(nameLabel, 1, 0, 1, 1);
}
let numLabel = new Gtk.Label({
label: getNumberLabel(row.number),
halign: Gtk.Align.START,
hexpand: true,
// TODO: rtl inverts margin-start so the number don't align
margin_start: (index > 0) ? 38 : 0,
margin_end: (index > 0) ? 38 : 0,
visible: true
});
numLabel.get_style_context().add_class('dim-label');
grid.attach(numLabel, 1, 1, 1, 1);
return row;
}
_addContact(contact) {
try {
// HACK: fix missing contact names
contact.name = contact.name || _('Unknown Contact');
if (contact.numbers.length === 1) {
return this._addContactNumber(contact, 0);
}
for (let i = 0, len = contact.numbers.length; i < len; i++) {
this._addContactNumber(contact, i);
}
} catch (e) {
logError(e);
}
}
/**
* Get a dictionary of number-contact pairs for each selected phone number.
*/
getSelected() {
try {
let selected = {};
for (let row of this.list.get_selected_rows()) {
selected[row.number.value] = row.contact;
}
return selected;
} catch (e) {
logError(e);
return {};
}
}
});

View File

@@ -0,0 +1,141 @@
'use strict';
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const URI = imports.utils.uri;
var ReplyDialog = GObject.registerClass({
GTypeName: 'GSConnectNotificationReplyDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The plugin that owns this notification',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
)
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/notification-reply-dialog.ui',
Children: ['infobar', 'notification-title', 'notification-body', 'entry']
}, class Dialog extends Gtk.Dialog {
_init(params) {
super._init({
application: Gio.Application.get_default(),
device: params.device,
plugin: params.plugin,
use_header_bar: true
});
this.uuid = params.uuid;
this.set_response_sensitive(Gtk.ResponseType.OK, false);
// Info bar
this.device.bind_property(
'connected',
this.infobar,
'reveal-child',
GObject.BindingFlags.INVERT_BOOLEAN
);
// Notification Data
let headerbar = this.get_titlebar();
headerbar.title = params.notification.appName;
headerbar.subtitle = this.device.name;
this.notification_title.label = params.notification.title;
this.notification_body.label = URI.linkify(params.notification.text);
// Message Entry/Send Button
this.device.bind_property(
'connected',
this.entry,
'sensitive',
GObject.BindingFlags.DEFAULT
);
this._connectedId = this.device.connect(
'notify::connected',
this._onStateChanged.bind(this)
);
this._entryChangedId = this.entry.buffer.connect(
'changed',
this._onStateChanged.bind(this)
);
this.restoreGeometry('notification-reply-dialog');
this.connect('destroy', this._onDestroy);
}
_onDestroy(dialog) {
dialog.entry.buffer.disconnect(dialog._entryChangedId);
dialog.device.disconnect(dialog._connectedId);
}
vfunc_delete_event() {
this.saveGeometry();
return false;
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
// Refuse to send empty or whitespace only messages
if (!this.entry.buffer.text.trim()) return;
this.plugin.replyNotification(
this.uuid,
this.entry.buffer.text
);
}
this.destroy();
}
get uuid() {
return this._uuid;
}
set uuid(uuid) {
// We must have a UUID
if (uuid) {
this._uuid = uuid;
} else {
this.destroy();
debug('no uuid for repliable notification');
}
}
get plugin() {
return this._plugin || null;
}
set plugin(plugin) {
this._plugin = plugin;
}
_onStateChanged() {
switch (false) {
case this.device.connected:
case (this.entry.buffer.text.trim().length):
break;
default:
this.set_response_sensitive(Gtk.ResponseType.OK, true);
}
}
});

View File

@@ -0,0 +1,159 @@
'use strict';
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
/**
* A dialog for selecting a device
*/
var DeviceChooserDialog = GObject.registerClass({
GTypeName: 'GSConnectDeviceChooserDialog'
}, class DeviceChooserDialog extends Gtk.Dialog {
_init(params) {
super._init({
use_header_bar: true,
application: Gio.Application.get_default(),
default_width: 300,
default_height: 200,
visible: true
});
this.set_keep_above(true);
//
this._action = params.action;
this._parameter = params.parameter;
// HeaderBar
let headerBar = this.get_header_bar();
headerBar.title = _('Select a Device');
headerBar.subtitle = params.title;
headerBar.show_close_button = false;
let selectButton = this.add_button(_('Select'), Gtk.ResponseType.OK);
selectButton.sensitive = false;
this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
this.set_default_response(Gtk.ResponseType.OK);
// Device List
let contentArea = this.get_content_area();
contentArea.border_width = 0;
let scrolledWindow = new Gtk.ScrolledWindow({
border_width: 0,
hexpand: true,
vexpand: true,
hscrollbar_policy: Gtk.PolicyType.NEVER,
visible: true
});
contentArea.add(scrolledWindow);
this.list = new Gtk.ListBox({
activate_on_single_click: false,
visible: true
});
scrolledWindow.add(this.list);
// Placeholder
let placeholder = new Gtk.Grid({
halign: Gtk.Align.CENTER,
valign: Gtk.Align.CENTER,
visible: true
});
placeholder.get_style_context().add_class('placeholder');
this.list.set_placeholder(placeholder);
let placeholderImage = new Gtk.Image({
icon_name: 'org.gnome.Shell.Extensions.GSConnect-symbolic',
pixel_size: 64,
visible: true
});
placeholderImage.get_style_context().add_class('placeholder-image');
placeholder.attach(placeholderImage, 0, 0, 1, 1);
let placeholderLabel = new Gtk.Label({
label: _('No Device Found'),
margin_top: 12,
visible: true
});
placeholderLabel.get_style_context().add_class('placeholder-title');
placeholder.attach(placeholderLabel, 0, 1, 1, 1);
this.list.connect(
'row-activated',
this._onDeviceActivated.bind(this)
);
this.list.connect(
'selected-rows-changed',
this._onDeviceSelected.bind(this)
);
this._populate();
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
try {
let device = this.list.get_selected_row().device;
device.activate_action(this._action, this._parameter);
} catch (e) {
logError(e);
}
}
this.destroy();
}
_onDeviceActivated(box, row) {
this.response(Gtk.ResponseType.OK);
}
_onDeviceSelected(box) {
this.set_response_sensitive(
Gtk.ResponseType.OK,
(box.get_selected_row())
);
}
_populate() {
for (let device of this.application._devices.values()) {
let action = device.lookup_action(this._action);
let row = new Gtk.ListBoxRow({
visible: action.enabled
});
action.bind_property('enabled', row, 'visible', 0);
row.device = device;
let grid = new Gtk.Grid({
column_spacing: 12,
margin: 6,
visible: true
});
row.add(grid);
let icon = new Gtk.Image({
icon_name: device.icon_name,
pixel_size: 32,
visible: true
});
grid.attach(icon, 0, 0, 1, 1);
let name = new Gtk.Label({
label: device.name,
halign: Gtk.Align.START,
hexpand: true,
visible: true
});
grid.attach(name, 1, 0, 1, 1);
this.list.add(row);
}
this.list.select_row(this.list.get_row_at_index(0));
}
});

View File

@@ -0,0 +1,200 @@
'use strict';
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const Contacts = imports.service.ui.contacts;
const Messaging = imports.service.ui.messaging;
var LegacyMessagingDialog = GObject.registerClass({
GTypeName: 'GSConnectLegacyMessagingDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The plugin providing messages',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
GObject.Object
)
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/telephony.ui',
Children: [
'infobar', 'stack',
'message-box', 'entry'
]
}, class Dialog extends Gtk.Dialog {
_init(params) {
super._init({
application: Gio.Application.get_default(),
device: params.device,
plugin: params.plugin,
use_header_bar: true
});
this.set_response_sensitive(Gtk.ResponseType.OK, false);
// Dup some functions
this.headerbar = this.get_titlebar();
this._setHeaderBar = Messaging.Window.prototype._setHeaderBar;
// Info bar
this.device.bind_property(
'connected',
this.infobar,
'reveal-child',
GObject.BindingFlags.INVERT_BOOLEAN
);
// Message Entry/Send Button
this.device.bind_property(
'connected',
this.entry,
'sensitive',
GObject.BindingFlags.DEFAULT
);
this._connectedId = this.device.connect(
'notify::connected',
this._onStateChanged.bind(this)
);
this._entryChangedId = this.entry.buffer.connect(
'changed',
this._onStateChanged.bind(this)
);
// Set the message if given
if (params.message) {
this.message = params.message;
this.addresses = params.message.addresses;
let label = new Messaging.MessageLabel(this.message);
label.margin_bottom = 12;
this.message_box.add(label);
// Otherwise set the address(es) if we were passed those
} else if (params.addresses) {
this.addresses = params.addresses;
}
// Load the contact list if we weren't supplied with an address
if (this.addresses.length === 0) {
this.contact_chooser = new Contacts.ContactChooser({
device: this.device
});
this.stack.add_named(this.contact_chooser, 'contact-chooser');
this.stack.child_set_property(this.contact_chooser, 'position', 0);
this._numberSelectedId = this.contact_chooser.connect(
'number-selected',
this._onNumberSelected.bind(this)
);
this.stack.visible_child_name = 'contact-chooser';
}
this.restoreGeometry('legacy-messaging-dialog');
this.connect('destroy', this._onDestroy);
}
_onDestroy(dialog) {
if (dialog._numberSelectedId !== undefined) {
dialog.contact_chooser.disconnect(dialog._numberSelectedId);
dialog.contact_chooser.destroy();
}
dialog.entry.buffer.disconnect(dialog._entryChangedId);
dialog.device.disconnect(dialog._connectedId);
}
vfunc_delete_event() {
this.saveGeometry();
return false;
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
// Refuse to send empty or whitespace only texts
if (!this.entry.buffer.text.trim()) return;
this.plugin.sendMessage(
this.addresses,
this.entry.buffer.text,
1,
true
);
}
this.destroy();
}
get addresses() {
if (this._addresses === undefined) {
this._addresses = [];
}
return this._addresses;
}
set addresses(addresses = []) {
this._addresses = addresses;
// Set the headerbar
this._setHeaderBar(this._addresses);
// Show the message editor
this.stack.visible_child_name = 'message-editor';
this._onStateChanged();
}
get plugin() {
return this._plugin || null;
}
set plugin(plugin) {
this._plugin = plugin;
}
_onNumberSelected(chooser, number) {
let contacts = chooser.getSelected();
this.addresses = Object.keys(contacts).map(address => {
return {address: address};
});
}
_onStateChanged() {
switch (false) {
case this.device.connected:
case (this.entry.buffer.text.trim().length):
case (this.stack.visible_child_name === 'message-editor'):
this.set_response_sensitive(Gtk.ResponseType.OK, false);
break;
default:
this.set_response_sensitive(Gtk.ResponseType.OK, true);
}
}
/**
* Set the contents of the message entry
*
* @param {String} text - The message to place in the entry
*/
setMessage(text) {
this.entry.buffer.text = text;
}
});