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,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();
}
});