mirror of
https://gitlab.com/thebiblelover7/dotfiles.git
synced 2025-12-14 04:03:50 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
'use strict';
|
||||
|
||||
const ByteArray = imports.byteArray;
|
||||
const Gettext = imports.gettext;
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GIRepository = imports.gi.GIRepository;
|
||||
const GLib = imports.gi.GLib;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
|
||||
// User Directories
|
||||
Config.CACHEDIR = GLib.build_filenamev([GLib.get_user_cache_dir(), 'gsconnect']);
|
||||
Config.CONFIGDIR = GLib.build_filenamev([GLib.get_user_config_dir(), 'gsconnect']);
|
||||
Config.RUNTIMEDIR = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gsconnect']);
|
||||
|
||||
|
||||
// Ensure config.js is setup properly
|
||||
const userDir = GLib.build_filenamev([GLib.get_user_data_dir(), 'gnome-shell']);
|
||||
|
||||
if (Config.PACKAGE_DATADIR.startsWith(userDir)) {
|
||||
Config.IS_USER = true;
|
||||
|
||||
Config.GSETTINGS_SCHEMA_DIR = `${Config.PACKAGE_DATADIR}/schemas`;
|
||||
Config.PACKAGE_LOCALEDIR = `${Config.PACKAGE_DATADIR}/locale`;
|
||||
|
||||
// Infer libdir by assuming gnome-shell shares a common prefix with gjs;
|
||||
// assume the parent directory if it's not there
|
||||
let libdir = GIRepository.Repository.get_search_path().find(path => {
|
||||
return path.endsWith('/gjs/girepository-1.0');
|
||||
}).replace('/gjs/girepository-1.0', '');
|
||||
|
||||
const gsdir = GLib.build_filenamev([libdir, 'gnome-shell']);
|
||||
|
||||
if (!GLib.file_test(gsdir, GLib.FileTest.IS_DIR)) {
|
||||
const currentDir = `/${GLib.path_get_basename(libdir)}`;
|
||||
libdir = libdir.replace(currentDir, '');
|
||||
}
|
||||
|
||||
Config.GNOME_SHELL_LIBDIR = libdir;
|
||||
}
|
||||
|
||||
|
||||
// Init Gettext
|
||||
String.prototype.format = imports.format.format;
|
||||
Gettext.bindtextdomain(Config.APP_ID, Config.PACKAGE_LOCALEDIR);
|
||||
globalThis._ = GLib.dgettext.bind(null, Config.APP_ID);
|
||||
globalThis.ngettext = GLib.dngettext.bind(null, Config.APP_ID);
|
||||
|
||||
|
||||
// Init GResources
|
||||
Gio.Resource.load(
|
||||
GLib.build_filenamev([Config.PACKAGE_DATADIR, `${Config.APP_ID}.gresource`])
|
||||
)._register();
|
||||
|
||||
|
||||
// Init GSchema
|
||||
Config.GSCHEMA = Gio.SettingsSchemaSource.new_from_directory(
|
||||
Config.GSETTINGS_SCHEMA_DIR,
|
||||
Gio.SettingsSchemaSource.get_default(),
|
||||
false
|
||||
);
|
||||
|
||||
|
||||
// Load DBus interfaces
|
||||
Config.DBUS = (() => {
|
||||
const bytes = Gio.resources_lookup_data(
|
||||
GLib.build_filenamev([Config.APP_PATH, `${Config.APP_ID}.xml`]),
|
||||
Gio.ResourceLookupFlags.NONE
|
||||
);
|
||||
|
||||
const xml = ByteArray.toString(bytes.toArray());
|
||||
const dbus = Gio.DBusNodeInfo.new_for_xml(xml);
|
||||
dbus.nodes.forEach(info => info.cache_build());
|
||||
|
||||
return dbus;
|
||||
})();
|
||||
|
||||
|
||||
// Init User Directories
|
||||
for (const path of [Config.CACHEDIR, Config.CONFIGDIR, Config.RUNTIMEDIR])
|
||||
GLib.mkdir_with_parents(path, 0o755);
|
||||
|
||||
|
||||
/**
|
||||
* Check if we're in a Wayland session (mostly for input synthesis)
|
||||
* https://wiki.gnome.org/Accessibility/Wayland#Bugs.2FIssues_We_Must_Address
|
||||
*/
|
||||
globalThis.HAVE_REMOTEINPUT = GLib.getenv('GDMSESSION') !== 'ubuntu-wayland';
|
||||
globalThis.HAVE_WAYLAND = GLib.getenv('XDG_SESSION_TYPE') === 'wayland';
|
||||
|
||||
|
||||
/**
|
||||
* A custom debug function that logs at LEVEL_MESSAGE to avoid the need for env
|
||||
* variables to be set.
|
||||
*
|
||||
* @param {Error|string} message - A string or Error to log
|
||||
* @param {string} [prefix] - An optional prefix for the warning
|
||||
*/
|
||||
const _debugCallerMatch = new RegExp(/([^@]*)@([^:]*):([^:]*)/);
|
||||
// eslint-disable-next-line func-style
|
||||
const _debugFunc = function (error, prefix = null) {
|
||||
let caller, message;
|
||||
|
||||
if (error.stack) {
|
||||
caller = error.stack.split('\n')[0];
|
||||
message = `${error.message}\n${error.stack}`;
|
||||
} else {
|
||||
caller = (new Error()).stack.split('\n')[1];
|
||||
message = JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
if (prefix)
|
||||
message = `${prefix}: ${message}`;
|
||||
|
||||
const [, func, file, line] = _debugCallerMatch.exec(caller);
|
||||
const script = file.replace(Config.PACKAGE_DATADIR, '');
|
||||
|
||||
GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, {
|
||||
'MESSAGE': `[${script}:${func}:${line}]: ${message}`,
|
||||
'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect',
|
||||
'CODE_FILE': file,
|
||||
'CODE_FUNC': func,
|
||||
'CODE_LINE': line,
|
||||
});
|
||||
};
|
||||
|
||||
// Swap the function out for a no-op anonymous function for speed
|
||||
const settings = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
|
||||
});
|
||||
|
||||
settings.connect('changed::debug', (settings, key) => {
|
||||
globalThis.debug = settings.get_boolean(key) ? _debugFunc : () => {};
|
||||
});
|
||||
|
||||
if (settings.get_boolean('debug'))
|
||||
globalThis.debug = _debugFunc;
|
||||
else
|
||||
globalThis.debug = () => {};
|
||||
|
||||
|
||||
/**
|
||||
* A simple (for now) pre-comparison sanitizer for phone numbers
|
||||
* See: https://github.com/KDE/kdeconnect-kde/blob/master/smsapp/conversationlistmodel.cpp#L200-L210
|
||||
*
|
||||
* @return {string} Return the string stripped of leading 0, and ' ()-+'
|
||||
*/
|
||||
String.prototype.toPhoneNumber = function () {
|
||||
const strippedNumber = this.replace(/^0*|[ ()+-]/g, '');
|
||||
|
||||
if (strippedNumber.length)
|
||||
return strippedNumber;
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A simple equality check for phone numbers based on `toPhoneNumber()`
|
||||
*
|
||||
* @param {string} number - A phone number string to compare
|
||||
* @return {boolean} If `this` and @number are equivalent phone numbers
|
||||
*/
|
||||
String.prototype.equalsPhoneNumber = function (number) {
|
||||
const a = this.toPhoneNumber();
|
||||
const b = number.toPhoneNumber();
|
||||
|
||||
return (a.endsWith(b) || b.endsWith(a));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An implementation of `rm -rf` in Gio
|
||||
*
|
||||
* @param {Gio.File|string} file - a GFile or filepath
|
||||
*/
|
||||
Gio.File.rm_rf = function (file) {
|
||||
try {
|
||||
if (typeof file === 'string')
|
||||
file = Gio.File.new_for_path(file);
|
||||
|
||||
try {
|
||||
const iter = file.enumerate_children(
|
||||
'standard::name',
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
null
|
||||
);
|
||||
|
||||
let info;
|
||||
|
||||
while ((info = iter.next_file(null)))
|
||||
Gio.File.rm_rf(iter.get_child(info));
|
||||
|
||||
iter.close(null);
|
||||
} catch (e) {
|
||||
// Silence errors
|
||||
}
|
||||
|
||||
file.delete(null);
|
||||
} catch (e) {
|
||||
// Silence errors
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Extend GLib.Variant with a static method to recursively pack a variant
|
||||
*
|
||||
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
|
||||
* @return {GLib.Variant} The resulting GVariant
|
||||
*/
|
||||
function _full_pack(obj) {
|
||||
let packed;
|
||||
const type = typeof obj;
|
||||
|
||||
switch (true) {
|
||||
case (obj instanceof GLib.Variant):
|
||||
return obj;
|
||||
|
||||
case (type === 'string'):
|
||||
return GLib.Variant.new('s', obj);
|
||||
|
||||
case (type === 'number'):
|
||||
return GLib.Variant.new('d', obj);
|
||||
|
||||
case (type === 'boolean'):
|
||||
return GLib.Variant.new('b', obj);
|
||||
|
||||
case (obj instanceof Uint8Array):
|
||||
return GLib.Variant.new('ay', obj);
|
||||
|
||||
case (obj === null):
|
||||
return GLib.Variant.new('mv', null);
|
||||
|
||||
case (typeof obj.map === 'function'):
|
||||
return GLib.Variant.new(
|
||||
'av',
|
||||
obj.filter(e => e !== undefined).map(e => _full_pack(e))
|
||||
);
|
||||
|
||||
case (obj instanceof Gio.Icon):
|
||||
return obj.serialize();
|
||||
|
||||
case (type === 'object'):
|
||||
packed = {};
|
||||
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (val !== undefined)
|
||||
packed[key] = _full_pack(val);
|
||||
}
|
||||
|
||||
return GLib.Variant.new('a{sv}', packed);
|
||||
|
||||
default:
|
||||
throw Error(`Unsupported type '${type}': ${obj}`);
|
||||
}
|
||||
}
|
||||
|
||||
GLib.Variant.full_pack = _full_pack;
|
||||
|
||||
|
||||
/**
|
||||
* Extend GLib.Variant with a method to recursively deepUnpack() a variant
|
||||
*
|
||||
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
|
||||
* @return {*} The resulting object
|
||||
*/
|
||||
function _full_unpack(obj) {
|
||||
obj = (obj === undefined) ? this : obj;
|
||||
const unpacked = {};
|
||||
|
||||
switch (true) {
|
||||
case (obj === null):
|
||||
return obj;
|
||||
|
||||
case (obj instanceof GLib.Variant):
|
||||
return _full_unpack(obj.deepUnpack());
|
||||
|
||||
case (obj instanceof Uint8Array):
|
||||
return obj;
|
||||
|
||||
case (typeof obj.map === 'function'):
|
||||
return obj.map(e => _full_unpack(e));
|
||||
|
||||
case (typeof obj === 'object'):
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Try to detect and deserialize GIcons
|
||||
try {
|
||||
if (key === 'icon' && value.get_type_string() === '(sv)')
|
||||
unpacked[key] = Gio.Icon.deserialize(value);
|
||||
else
|
||||
unpacked[key] = _full_unpack(value);
|
||||
} catch (e) {
|
||||
unpacked[key] = _full_unpack(value);
|
||||
}
|
||||
}
|
||||
|
||||
return unpacked;
|
||||
|
||||
default:
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
GLib.Variant.prototype.full_unpack = _full_unpack;
|
||||
|
||||
|
||||
/**
|
||||
* Creates a GTlsCertificate from the PEM-encoded data in @cert_path and
|
||||
* @key_path. If either are missing a new pair will be generated.
|
||||
*
|
||||
* Additionally, the private key will be added using ssh-add to allow sftp
|
||||
* connections using Gio.
|
||||
*
|
||||
* See: https://github.com/KDE/kdeconnect-kde/blob/master/core/kdeconnectconfig.cpp#L119
|
||||
*
|
||||
* @param {string} certPath - Absolute path to a x509 certificate in PEM format
|
||||
* @param {string} keyPath - Absolute path to a private key in PEM format
|
||||
* @param {string} commonName - A unique common name for the certificate
|
||||
* @return {Gio.TlsCertificate} A TLS certificate
|
||||
*/
|
||||
Gio.TlsCertificate.new_for_paths = function (certPath, keyPath, commonName = null) {
|
||||
// Check if the certificate/key pair already exists
|
||||
const certExists = GLib.file_test(certPath, GLib.FileTest.EXISTS);
|
||||
const keyExists = GLib.file_test(keyPath, GLib.FileTest.EXISTS);
|
||||
|
||||
// Create a new certificate and private key if necessary
|
||||
if (!certExists || !keyExists) {
|
||||
// If we weren't passed a common name, generate a random one
|
||||
if (!commonName)
|
||||
commonName = GLib.uuid_string_random();
|
||||
|
||||
const proc = new Gio.Subprocess({
|
||||
argv: [
|
||||
Config.OPENSSL_PATH, 'req',
|
||||
'-new', '-x509', '-sha256',
|
||||
'-out', certPath,
|
||||
'-newkey', 'rsa:4096', '-nodes',
|
||||
'-keyout', keyPath,
|
||||
'-days', '3650',
|
||||
'-subj', `/O=andyholmes.github.io/OU=GSConnect/CN=${commonName}`,
|
||||
],
|
||||
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
|
||||
Gio.SubprocessFlags.STDERR_SILENCE),
|
||||
});
|
||||
proc.init(null);
|
||||
proc.wait_check(null);
|
||||
}
|
||||
|
||||
return Gio.TlsCertificate.new_from_files(certPath, keyPath);
|
||||
};
|
||||
|
||||
Object.defineProperties(Gio.TlsCertificate.prototype, {
|
||||
/**
|
||||
* Compute a SHA256 fingerprint of the certificate.
|
||||
* See: https://gitlab.gnome.org/GNOME/glib/issues/1290
|
||||
*
|
||||
* @return {string} A SHA256 fingerprint of the certificate.
|
||||
*/
|
||||
'sha256': {
|
||||
value: function () {
|
||||
if (!this.__fingerprint) {
|
||||
const proc = new Gio.Subprocess({
|
||||
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-fingerprint', '-sha256', '-inform', 'pem'],
|
||||
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
|
||||
});
|
||||
proc.init(null);
|
||||
|
||||
const stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
|
||||
this.__fingerprint = /[a-zA-Z0-9:]{95}/.exec(stdout)[0];
|
||||
}
|
||||
|
||||
return this.__fingerprint;
|
||||
},
|
||||
enumerable: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* The common name of the certificate.
|
||||
*/
|
||||
'common_name': {
|
||||
get: function () {
|
||||
if (!this.__common_name) {
|
||||
const proc = new Gio.Subprocess({
|
||||
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-subject', '-inform', 'pem'],
|
||||
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
|
||||
});
|
||||
proc.init(null);
|
||||
|
||||
const stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
|
||||
this.__common_name = /(?:cn|CN) ?= ?([^,\n]*)/.exec(stdout)[1];
|
||||
}
|
||||
|
||||
return this.__common_name;
|
||||
},
|
||||
enumerable: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const Core = imports.service.core;
|
||||
|
||||
|
||||
/**
|
||||
* TCP Port Constants
|
||||
*/
|
||||
const DEFAULT_PORT = 1716;
|
||||
const TRANSFER_MIN = 1739;
|
||||
const TRANSFER_MAX = 1764;
|
||||
|
||||
|
||||
/*
|
||||
* One-time check for Linux/FreeBSD socket options
|
||||
*/
|
||||
var _LINUX_SOCKETS = true;
|
||||
|
||||
try {
|
||||
// This should throw on FreeBSD
|
||||
Gio.Socket.new(
|
||||
Gio.SocketFamily.IPV4,
|
||||
Gio.SocketType.STREAM,
|
||||
Gio.SocketProtocol.TCP
|
||||
).get_option(6, 5);
|
||||
} catch (e) {
|
||||
_LINUX_SOCKETS = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure a socket connection for the KDE Connect protocol.
|
||||
*
|
||||
* @param {Gio.SocketConnection} connection - The connection to configure
|
||||
*/
|
||||
function _configureSocket(connection) {
|
||||
try {
|
||||
if (_LINUX_SOCKETS) {
|
||||
connection.socket.set_option(6, 4, 10); // TCP_KEEPIDLE
|
||||
connection.socket.set_option(6, 5, 5); // TCP_KEEPINTVL
|
||||
connection.socket.set_option(6, 6, 3); // TCP_KEEPCNT
|
||||
|
||||
// FreeBSD constants
|
||||
// https://github.com/freebsd/freebsd/blob/master/sys/netinet/tcp.h#L159
|
||||
} else {
|
||||
connection.socket.set_option(6, 256, 10); // TCP_KEEPIDLE
|
||||
connection.socket.set_option(6, 512, 5); // TCP_KEEPINTVL
|
||||
connection.socket.set_option(6, 1024, 3); // TCP_KEEPCNT
|
||||
}
|
||||
|
||||
// Do this last because an error setting the keepalive options would
|
||||
// result in a socket that never times out
|
||||
connection.socket.set_keepalive(true);
|
||||
} catch (e) {
|
||||
debug(e, 'Configuring Socket');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lan.ChannelService consists of two parts:
|
||||
*
|
||||
* The TCP Listener listens on a port and constructs a Channel object from the
|
||||
* incoming Gio.TcpConnection.
|
||||
*
|
||||
* The UDP Listener listens on a port for incoming JSON identity packets which
|
||||
* include the TCP port, while the IP address is taken from the UDP packet
|
||||
* itself. We respond by opening a TCP connection to that address.
|
||||
*/
|
||||
var ChannelService = GObject.registerClass({
|
||||
GTypeName: 'GSConnectLanChannelService',
|
||||
Properties: {
|
||||
'certificate': GObject.ParamSpec.object(
|
||||
'certificate',
|
||||
'Certificate',
|
||||
'The TLS certificate',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
Gio.TlsCertificate.$gtype
|
||||
),
|
||||
'port': GObject.ParamSpec.uint(
|
||||
'port',
|
||||
'Port',
|
||||
'The port used by the service',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
0, GLib.MAXUINT16,
|
||||
DEFAULT_PORT
|
||||
),
|
||||
},
|
||||
}, class LanChannelService extends Core.ChannelService {
|
||||
|
||||
_init(params = {}) {
|
||||
super._init(params);
|
||||
|
||||
// Track hosts we identify to directly, allowing them to ignore the
|
||||
// discoverable state of the service.
|
||||
this._allowed = new Set();
|
||||
|
||||
//
|
||||
this._tcp = null;
|
||||
this._udp4 = null;
|
||||
this._udp6 = null;
|
||||
|
||||
// Monitor network status
|
||||
this._networkMonitor = Gio.NetworkMonitor.get_default();
|
||||
this._networkAvailable = false;
|
||||
this._networkChangedId = 0;
|
||||
}
|
||||
|
||||
get certificate() {
|
||||
if (this._certificate === undefined)
|
||||
this._certificate = null;
|
||||
|
||||
return this._certificate;
|
||||
}
|
||||
|
||||
set certificate(certificate) {
|
||||
if (this.certificate === certificate)
|
||||
return;
|
||||
|
||||
this._certificate = certificate;
|
||||
this.notify('certificate');
|
||||
}
|
||||
|
||||
get channels() {
|
||||
if (this._channels === undefined)
|
||||
this._channels = new Map();
|
||||
|
||||
return this._channels;
|
||||
}
|
||||
|
||||
get port() {
|
||||
if (this._port === undefined)
|
||||
this._port = DEFAULT_PORT;
|
||||
|
||||
return this._port;
|
||||
}
|
||||
|
||||
set port(port) {
|
||||
if (this.port === port)
|
||||
return;
|
||||
|
||||
this._port = port;
|
||||
this.notify('port');
|
||||
}
|
||||
|
||||
_onNetworkChanged(monitor, network_available) {
|
||||
if (this._networkAvailable === network_available)
|
||||
return;
|
||||
|
||||
this._networkAvailable = network_available;
|
||||
this.broadcast();
|
||||
}
|
||||
|
||||
_initCertificate() {
|
||||
if (GLib.find_program_in_path(Config.OPENSSL_PATH) === null) {
|
||||
const error = new Error();
|
||||
error.name = _('OpenSSL not found');
|
||||
error.url = `${Config.PACKAGE_URL}/wiki/Error#openssl-not-found`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const certPath = GLib.build_filenamev([
|
||||
Config.CONFIGDIR,
|
||||
'certificate.pem',
|
||||
]);
|
||||
const keyPath = GLib.build_filenamev([
|
||||
Config.CONFIGDIR,
|
||||
'private.pem',
|
||||
]);
|
||||
|
||||
// Ensure a certificate exists with our id as the common name
|
||||
this._certificate = Gio.TlsCertificate.new_for_paths(certPath, keyPath,
|
||||
this.id);
|
||||
|
||||
// If the service ID doesn't match the common name, this is probably a
|
||||
// certificate from an older version and we should amend ours to match
|
||||
if (this.id !== this._certificate.common_name)
|
||||
this._id = this._certificate.common_name;
|
||||
}
|
||||
|
||||
_initTcpListener() {
|
||||
try {
|
||||
this._tcp = new Gio.SocketService();
|
||||
this._tcp.add_inet_port(this.port, null);
|
||||
this._tcp.connect('incoming', this._onIncomingChannel.bind(this));
|
||||
} catch (e) {
|
||||
this._tcp = null;
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async _onIncomingChannel(listener, connection) {
|
||||
try {
|
||||
const host = connection.get_remote_address().address.to_string();
|
||||
|
||||
// Create a channel
|
||||
const channel = new Channel({
|
||||
backend: this,
|
||||
certificate: this.certificate,
|
||||
host: host,
|
||||
port: this.port,
|
||||
});
|
||||
|
||||
// Accept the connection
|
||||
await channel.accept(connection);
|
||||
channel.identity.body.tcpHost = channel.host;
|
||||
channel.identity.body.tcpPort = this.port;
|
||||
channel.allowed = this._allowed.has(host);
|
||||
|
||||
this.channel(channel);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
_initUdpListener() {
|
||||
// Default broadcast address
|
||||
this._udp_address = Gio.InetSocketAddress.new_from_string(
|
||||
'255.255.255.255',
|
||||
this.port
|
||||
);
|
||||
|
||||
try {
|
||||
this._udp6 = Gio.Socket.new(
|
||||
Gio.SocketFamily.IPV6,
|
||||
Gio.SocketType.DATAGRAM,
|
||||
Gio.SocketProtocol.UDP
|
||||
);
|
||||
this._udp6.set_broadcast(true);
|
||||
|
||||
// Bind the socket
|
||||
const inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV6);
|
||||
const sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
|
||||
this._udp6.bind(sockAddr, false);
|
||||
|
||||
// Input stream
|
||||
this._udp6_stream = new Gio.DataInputStream({
|
||||
base_stream: new Gio.UnixInputStream({
|
||||
fd: this._udp6.fd,
|
||||
close_fd: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Watch socket for incoming packets
|
||||
this._udp6_source = this._udp6.create_source(GLib.IOCondition.IN, null);
|
||||
this._udp6_source.set_callback(this._onIncomingIdentity.bind(this, this._udp6));
|
||||
this._udp6_source.attach(null);
|
||||
} catch (e) {
|
||||
this._udp6 = null;
|
||||
}
|
||||
|
||||
// Our IPv6 socket also supports IPv4; we're all done
|
||||
if (this._udp6 && this._udp6.speaks_ipv4()) {
|
||||
this._udp4 = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._udp4 = Gio.Socket.new(
|
||||
Gio.SocketFamily.IPV4,
|
||||
Gio.SocketType.DATAGRAM,
|
||||
Gio.SocketProtocol.UDP
|
||||
);
|
||||
this._udp4.set_broadcast(true);
|
||||
|
||||
// Bind the socket
|
||||
const inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV4);
|
||||
const sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
|
||||
this._udp4.bind(sockAddr, false);
|
||||
|
||||
// Input stream
|
||||
this._udp4_stream = new Gio.DataInputStream({
|
||||
base_stream: new Gio.UnixInputStream({
|
||||
fd: this._udp4.fd,
|
||||
close_fd: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Watch input socket for incoming packets
|
||||
this._udp4_source = this._udp4.create_source(GLib.IOCondition.IN, null);
|
||||
this._udp4_source.set_callback(this._onIncomingIdentity.bind(this, this._udp4));
|
||||
this._udp4_source.attach(null);
|
||||
} catch (e) {
|
||||
this._udp4 = null;
|
||||
|
||||
// We failed to get either an IPv4 or IPv6 socket to bind
|
||||
if (this._udp6 === null)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
_onIncomingIdentity(socket) {
|
||||
let host, data, packet;
|
||||
|
||||
// Try to peek the remote address
|
||||
try {
|
||||
host = socket.receive_message(
|
||||
[],
|
||||
Gio.SocketMsgFlags.PEEK,
|
||||
null
|
||||
)[1].address.to_string();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
|
||||
// Whether or not we peeked the address, we need to read the packet
|
||||
try {
|
||||
if (socket === this._udp6)
|
||||
data = this._udp6_stream.read_line_utf8(null)[0];
|
||||
else
|
||||
data = this._udp4_stream.read_line_utf8(null)[0];
|
||||
|
||||
// Discard the packet if we failed to peek the address
|
||||
if (host === undefined)
|
||||
return;
|
||||
|
||||
packet = new Core.Packet(data);
|
||||
packet.body.tcpHost = host;
|
||||
this._onIdentity(packet);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
async _onIdentity(packet) {
|
||||
try {
|
||||
// Bail if the deviceId is missing
|
||||
if (!packet.body.hasOwnProperty('deviceId'))
|
||||
return;
|
||||
|
||||
// Silently ignore our own broadcasts
|
||||
if (packet.body.deviceId === this.identity.body.deviceId)
|
||||
return;
|
||||
|
||||
debug(packet);
|
||||
|
||||
// Create a new channel
|
||||
const channel = new Channel({
|
||||
backend: this,
|
||||
certificate: this.certificate,
|
||||
host: packet.body.tcpHost,
|
||||
port: packet.body.tcpPort,
|
||||
identity: packet,
|
||||
});
|
||||
|
||||
// Check if channel is already open with this address
|
||||
if (this.channels.has(channel.address))
|
||||
return;
|
||||
|
||||
this._channels.set(channel.address, channel);
|
||||
|
||||
// Open a TCP connection
|
||||
const connection = await new Promise((resolve, reject) => {
|
||||
const address = Gio.InetSocketAddress.new_from_string(
|
||||
packet.body.tcpHost,
|
||||
packet.body.tcpPort
|
||||
);
|
||||
const client = new Gio.SocketClient({enable_proxy: false});
|
||||
|
||||
client.connect_async(address, null, (client, res) => {
|
||||
try {
|
||||
resolve(client.connect_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Connect the channel and attach it to the device on success
|
||||
await channel.open(connection);
|
||||
|
||||
this.channel(channel);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an identity packet
|
||||
*
|
||||
* If @address is not %null it may specify an IPv4 or IPv6 address to send
|
||||
* the identity packet directly to, otherwise it will be broadcast to the
|
||||
* default address, 255.255.255.255.
|
||||
*
|
||||
* @param {string} [address] - An optional target IPv4 or IPv6 address
|
||||
*/
|
||||
broadcast(address = null) {
|
||||
try {
|
||||
if (!this._networkAvailable)
|
||||
return;
|
||||
|
||||
// Try to parse strings as <host>:<port>
|
||||
if (typeof address === 'string') {
|
||||
const [host, portstr] = address.split(':');
|
||||
const port = parseInt(portstr) || this.port;
|
||||
address = Gio.InetSocketAddress.new_from_string(host, port);
|
||||
}
|
||||
|
||||
// If we succeed, remember this host
|
||||
if (address instanceof Gio.InetSocketAddress) {
|
||||
this._allowed.add(address.address.to_string());
|
||||
|
||||
// Broadcast to the network if no address is specified
|
||||
} else {
|
||||
debug('Broadcasting to LAN');
|
||||
address = this._udp_address;
|
||||
}
|
||||
|
||||
// Broadcast on each open socket
|
||||
if (this._udp6 !== null)
|
||||
this._udp6.send_to(address, this.identity.serialize(), null);
|
||||
|
||||
if (this._udp4 !== null)
|
||||
this._udp4.send_to(address, this.identity.serialize(), null);
|
||||
} catch (e) {
|
||||
debug(e, address);
|
||||
}
|
||||
}
|
||||
|
||||
buildIdentity() {
|
||||
// Chain-up, then add the TCP port
|
||||
super.buildIdentity();
|
||||
this.identity.body.tcpPort = this.port;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.active)
|
||||
return;
|
||||
|
||||
// Ensure a certificate exists
|
||||
if (this.certificate === null)
|
||||
this._initCertificate();
|
||||
|
||||
// Start TCP/UDP listeners
|
||||
try {
|
||||
if (this._tcp === null)
|
||||
this._initTcpListener();
|
||||
|
||||
if (this._udp4 === null && this._udp6 === null)
|
||||
this._initUdpListener();
|
||||
} catch (e) {
|
||||
// Known case of another application using the protocol defined port
|
||||
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ADDRESS_IN_USE)) {
|
||||
e.name = _('Port already in use');
|
||||
e.url = `${Config.PACKAGE_URL}/wiki/Error#port-already-in-use`;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Monitor network changes
|
||||
if (this._networkChangedId === 0) {
|
||||
this._networkAvailable = this._networkMonitor.network_available;
|
||||
this._networkChangedId = this._networkMonitor.connect(
|
||||
'network-changed',
|
||||
this._onNetworkChanged.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
this._active = true;
|
||||
this.notify('active');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._networkChangedId) {
|
||||
this._networkMonitor.disconnect(this._networkChangedId);
|
||||
this._networkChangedId = 0;
|
||||
this._networkAvailable = false;
|
||||
}
|
||||
|
||||
if (this._tcp !== null) {
|
||||
this._tcp.stop();
|
||||
this._tcp.close();
|
||||
this._tcp = null;
|
||||
}
|
||||
|
||||
if (this._udp6 !== null) {
|
||||
this._udp6_source.destroy();
|
||||
this._udp6_stream.close(null);
|
||||
this._udp6.close();
|
||||
this._udp6 = null;
|
||||
}
|
||||
|
||||
if (this._udp4 !== null) {
|
||||
this._udp4_source.destroy();
|
||||
this._udp4_stream.close(null);
|
||||
this._udp4.close();
|
||||
this._udp4 = null;
|
||||
}
|
||||
|
||||
for (const channel of this.channels.values())
|
||||
channel.close();
|
||||
|
||||
this._active = false;
|
||||
this.notify('active');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
this.stop();
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Lan Channel
|
||||
*
|
||||
* This class essentially just extends Core.Channel to set TCP socket options
|
||||
* and negotiate TLS encrypted connections.
|
||||
*/
|
||||
var Channel = GObject.registerClass({
|
||||
GTypeName: 'GSConnectLanChannel',
|
||||
}, class LanChannel extends Core.Channel {
|
||||
|
||||
_init(params) {
|
||||
super._init();
|
||||
Object.assign(this, params);
|
||||
}
|
||||
|
||||
get address() {
|
||||
return `lan://${this.host}:${this.port}`;
|
||||
}
|
||||
|
||||
get certificate() {
|
||||
if (this._certificate === undefined)
|
||||
this._certificate = null;
|
||||
|
||||
return this._certificate;
|
||||
}
|
||||
|
||||
set certificate(certificate) {
|
||||
this._certificate = certificate;
|
||||
}
|
||||
|
||||
get peer_certificate() {
|
||||
if (this._connection instanceof Gio.TlsConnection)
|
||||
return this._connection.get_peer_certificate();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get host() {
|
||||
if (this._host === undefined)
|
||||
this._host = null;
|
||||
|
||||
return this._host;
|
||||
}
|
||||
|
||||
set host(host) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
get port() {
|
||||
if (this._port === undefined) {
|
||||
if (this.identity && this.identity.body.tcpPort)
|
||||
this._port = this.identity.body.tcpPort;
|
||||
else
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
return this._port;
|
||||
}
|
||||
|
||||
set port(port) {
|
||||
this._port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handshake Gio.TlsConnection
|
||||
*
|
||||
* @param {Gio.TlsConnection} connection - A TLS connection
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_handshake(connection) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connection.validation_flags = Gio.TlsCertificateFlags.EXPIRED;
|
||||
connection.authentication_mode = Gio.TlsAuthenticationMode.REQUIRED;
|
||||
|
||||
connection.handshake_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(connection, res) => {
|
||||
try {
|
||||
resolve(connection.handshake_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a TLS connection.
|
||||
*
|
||||
* @param {Gio.TlsConnection} connection - A TLS connection
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
async _authenticate(connection) {
|
||||
// Standard TLS Handshake
|
||||
await this._handshake(connection);
|
||||
|
||||
// Get a settings object for the device
|
||||
let settings;
|
||||
|
||||
if (this.device) {
|
||||
settings = this.device.settings;
|
||||
} else {
|
||||
const id = this.identity.body.deviceId;
|
||||
settings = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(
|
||||
'org.gnome.Shell.Extensions.GSConnect.Device',
|
||||
true
|
||||
),
|
||||
path: `/org/gnome/shell/extensions/gsconnect/device/${id}/`,
|
||||
});
|
||||
}
|
||||
|
||||
// If we have a certificate for this deviceId, we can verify it
|
||||
const cert_pem = settings.get_string('certificate-pem');
|
||||
|
||||
if (cert_pem !== '') {
|
||||
let certificate = null;
|
||||
let verified = false;
|
||||
|
||||
try {
|
||||
certificate = Gio.TlsCertificate.new_from_pem(cert_pem, -1);
|
||||
verified = certificate.is_same(connection.peer_certificate);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
|
||||
/* The certificate is incorrect for one of two reasons, but both
|
||||
* result in us resetting the certificate and unpairing the device.
|
||||
*
|
||||
* If the certificate failed to load, it is probably corrupted or
|
||||
* otherwise invalid. In this case, if we try to continue we will
|
||||
* certainly crash the Android app.
|
||||
*
|
||||
* If the certificate did not match what we expected the obvious
|
||||
* thing to do is to notify the user, however experience tells us
|
||||
* this is a result of the user doing something masochistic like
|
||||
* nuking the Android app data or copying settings between machines.
|
||||
*/
|
||||
if (verified === false) {
|
||||
if (this.device) {
|
||||
this.device.unpair();
|
||||
} else {
|
||||
settings.reset('paired');
|
||||
settings.reset('certificate-pem');
|
||||
}
|
||||
|
||||
const name = this.identity.body.deviceName;
|
||||
throw new Error(`${name}: Authentication Failure`);
|
||||
}
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the connection in Gio.TlsClientConnection and initiate handshake
|
||||
*
|
||||
* @param {Gio.TcpConnection} connection - The unauthenticated connection
|
||||
* @return {Gio.TlsClientConnection} The authenticated connection
|
||||
*/
|
||||
_encryptClient(connection) {
|
||||
_configureSocket(connection);
|
||||
|
||||
connection = Gio.TlsClientConnection.new(
|
||||
connection,
|
||||
connection.socket.remote_address
|
||||
);
|
||||
connection.set_certificate(this.certificate);
|
||||
|
||||
return this._authenticate(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the connection in Gio.TlsServerConnection and initiate handshake
|
||||
*
|
||||
* @param {Gio.TcpConnection} connection - The unauthenticated connection
|
||||
* @return {Gio.TlsServerConnection} The authenticated connection
|
||||
*/
|
||||
_encryptServer(connection) {
|
||||
_configureSocket(connection);
|
||||
|
||||
connection = Gio.TlsServerConnection.new(connection, this.certificate);
|
||||
|
||||
// We're the server so we trust-on-first-use and verify after
|
||||
const _id = connection.connect('accept-certificate', (connection) => {
|
||||
connection.disconnect(_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
return this._authenticate(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the identity packet from the new connection
|
||||
*
|
||||
* @param {Gio.SocketConnection} connection - An unencrypted socket
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_receiveIdent(connection) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// In principle this disposable wrapper could buffer more than the
|
||||
// identity packet, but in practice the remote device shouldn't send
|
||||
// any more data until the TLS connection is negotiated.
|
||||
const stream = new Gio.DataInputStream({
|
||||
base_stream: connection.input_stream,
|
||||
close_base_stream: false,
|
||||
});
|
||||
|
||||
stream.read_line_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(stream, res) => {
|
||||
try {
|
||||
const data = stream.read_line_finish_utf8(res)[0];
|
||||
stream.close(null);
|
||||
|
||||
// Store the identity as an object property
|
||||
this.identity = new Core.Packet(data);
|
||||
|
||||
// Reject connections without a deviceId
|
||||
if (!this.identity.body.deviceId)
|
||||
throw new Error('missing deviceId');
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write our identity packet to the new connection
|
||||
*
|
||||
* @param {Gio.SocketConnection} connection - An unencrypted socket
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_sendIdent(connection) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connection.get_output_stream().write_all_async(
|
||||
this.backend.identity.serialize(),
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(stream, res) => {
|
||||
try {
|
||||
resolve(stream.write_all_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Negotiate an incoming connection
|
||||
*
|
||||
* @param {Gio.TcpConnection} connection - The incoming connection
|
||||
*/
|
||||
async accept(connection) {
|
||||
debug(`${this.address} (${this.uuid})`);
|
||||
|
||||
try {
|
||||
this._connection = connection;
|
||||
this.backend.channels.set(this.address, this);
|
||||
|
||||
await this._receiveIdent(this._connection);
|
||||
this._connection = await this._encryptClient(connection);
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Negotiate an outgoing connection
|
||||
*
|
||||
* @param {Gio.SocketConnection} connection - The remote connection
|
||||
*/
|
||||
async open(connection) {
|
||||
debug(`${this.address} (${this.uuid})`);
|
||||
|
||||
try {
|
||||
this._connection = connection;
|
||||
this.backend.channels.set(this.address, this);
|
||||
|
||||
await this._sendIdent(this._connection);
|
||||
this._connection = await this._encryptServer(connection);
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all streams associated with this channel, silencing any errors
|
||||
*/
|
||||
close() {
|
||||
if (this.closed)
|
||||
return;
|
||||
|
||||
debug(`${this.address} (${this.uuid})`);
|
||||
this._closed = true;
|
||||
this.notify('closed');
|
||||
|
||||
this.backend.channels.delete(this.address);
|
||||
this.cancellable.cancel();
|
||||
|
||||
if (this._connection)
|
||||
this._connection.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
|
||||
if (this.input_stream)
|
||||
this.input_stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
|
||||
if (this.output_stream)
|
||||
this.output_stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
}
|
||||
|
||||
async download(packet, target, cancellable = null) {
|
||||
const openConnection = new Promise((resolve, reject) => {
|
||||
const client = new Gio.SocketClient({enable_proxy: false});
|
||||
|
||||
const address = Gio.InetSocketAddress.new_from_string(
|
||||
this.host,
|
||||
packet.payloadTransferInfo.port
|
||||
);
|
||||
|
||||
client.connect_async(address, cancellable, (client, res) => {
|
||||
try {
|
||||
resolve(client.connect_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let connection = await openConnection;
|
||||
connection = await this._encryptClient(connection);
|
||||
const source = connection.get_input_stream();
|
||||
|
||||
// Start the transfer
|
||||
const transferredSize = await this._transfer(source, target, cancellable);
|
||||
|
||||
// If we get less than expected, we've certainly got corruption
|
||||
if (transferredSize < packet.payloadSize) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.FAILED,
|
||||
message: `Incomplete: ${transferredSize}/${packet.payloadSize}`,
|
||||
});
|
||||
|
||||
// TODO: sometimes kdeconnect-android under-reports a file's size
|
||||
// https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/1157
|
||||
} else if (transferredSize > packet.payloadSize) {
|
||||
logError(new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.FAILED,
|
||||
message: `Extra Data: ${transferredSize - packet.payloadSize}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async upload(packet, source, size, cancellable = null) {
|
||||
// Start listening on the first available port between 1739-1764
|
||||
const listener = new Gio.SocketListener();
|
||||
let port = TRANSFER_MIN;
|
||||
|
||||
while (port <= TRANSFER_MAX) {
|
||||
try {
|
||||
listener.add_inet_port(port, null);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (port < TRANSFER_MAX) {
|
||||
port++;
|
||||
continue;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the incoming connection
|
||||
const acceptConnection = new Promise((resolve, reject) => {
|
||||
listener.accept_async(
|
||||
cancellable,
|
||||
(listener, res, source_object) => {
|
||||
try {
|
||||
resolve(listener.accept_finish(res)[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Notify the device we're ready
|
||||
packet.body.payloadHash = this.checksum;
|
||||
packet.payloadSize = size;
|
||||
packet.payloadTransferInfo = {port: port};
|
||||
this.sendPacket(new Core.Packet(packet));
|
||||
|
||||
// Accept the connection and configure the channel
|
||||
let connection = await acceptConnection;
|
||||
connection = await this._encryptServer(connection);
|
||||
const target = connection.get_output_stream();
|
||||
|
||||
// Start the transfer
|
||||
const transferredSize = await this._transfer(source, target, cancellable);
|
||||
|
||||
if (transferredSize !== size) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.PARTIAL_INPUT,
|
||||
message: 'Transfer incomplete',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_transfer(source, target, cancellable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
target.splice_async(
|
||||
source,
|
||||
(Gio.OutputStreamSpliceFlags.CLOSE_SOURCE |
|
||||
Gio.OutputStreamSpliceFlags.CLOSE_TARGET),
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
cancellable,
|
||||
(target, res) => {
|
||||
try {
|
||||
resolve(target.splice_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async rejectTransfer(packet) {
|
||||
try {
|
||||
if (!packet || !packet.hasPayload())
|
||||
return;
|
||||
|
||||
if (packet.payloadTransferInfo.port === undefined)
|
||||
return;
|
||||
|
||||
let connection = await new Promise((resolve, reject) => {
|
||||
const client = new Gio.SocketClient({enable_proxy: false});
|
||||
|
||||
const address = Gio.InetSocketAddress.new_from_string(
|
||||
this.host,
|
||||
packet.payloadTransferInfo.port
|
||||
);
|
||||
|
||||
client.connect_async(address, null, (client, res) => {
|
||||
try {
|
||||
resolve(client.connect_finish(res));
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connection = await this._encryptClient(connection);
|
||||
connection.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
|
||||
/*
|
||||
* Singleton Tracker
|
||||
*/
|
||||
const Default = new Map();
|
||||
|
||||
|
||||
/**
|
||||
* Acquire a reference to a component. Calls to this function should always be
|
||||
* followed by a call to `release()`.
|
||||
*
|
||||
* @param {string} name - The module name
|
||||
* @return {*} The default instance of a component
|
||||
*/
|
||||
function acquire(name) {
|
||||
let component;
|
||||
|
||||
try {
|
||||
let info = Default.get(name);
|
||||
|
||||
if (info === undefined) {
|
||||
const module = imports.service.components[name];
|
||||
|
||||
info = {
|
||||
instance: new module.Component(),
|
||||
refcount: 0,
|
||||
};
|
||||
|
||||
Default.set(name, info);
|
||||
}
|
||||
|
||||
info.refcount++;
|
||||
component = info.instance;
|
||||
} catch (e) {
|
||||
debug(e, name);
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Release a reference on a component. If the caller was the last reference
|
||||
* holder, the component will be freed.
|
||||
*
|
||||
* @param {string} name - The module name
|
||||
* @return {null} A %null value, useful for overriding a traced variable
|
||||
*/
|
||||
function release(name) {
|
||||
try {
|
||||
const info = Default.get(name);
|
||||
|
||||
if (info.refcount === 1) {
|
||||
info.instance.destroy();
|
||||
Default.delete(name);
|
||||
}
|
||||
|
||||
info.refcount--;
|
||||
} catch (e) {
|
||||
debug(e, name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
'use strict';
|
||||
|
||||
imports.gi.versions.Atspi = '2.0';
|
||||
|
||||
const Atspi = imports.gi.Atspi;
|
||||
const Gdk = imports.gi.Gdk;
|
||||
|
||||
|
||||
/**
|
||||
* Printable ASCII range
|
||||
*/
|
||||
const _ASCII = /[\x20-\x7E]/;
|
||||
|
||||
|
||||
/**
|
||||
* Modifier Keycode Defaults
|
||||
*/
|
||||
const XKeycode = {
|
||||
Alt_L: 0x40,
|
||||
Control_L: 0x25,
|
||||
Shift_L: 0x32,
|
||||
Super_L: 0x85,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A thin wrapper around Atspi for X11 sessions without Pipewire support.
|
||||
*/
|
||||
var Controller = class {
|
||||
constructor() {
|
||||
// Atspi.init() return 2 on fail, but still marks itself as inited. We
|
||||
// uninit before throwing an error otherwise any future call to init()
|
||||
// will appear successful and other calls will cause GSConnect to exit.
|
||||
// See: https://gitlab.gnome.org/GNOME/at-spi2-core/blob/master/atspi/atspi-misc.c
|
||||
if (Atspi.init() === 2) {
|
||||
this.destroy();
|
||||
throw new Error('Failed to start AT-SPI');
|
||||
}
|
||||
|
||||
try {
|
||||
this._display = Gdk.Display.get_default();
|
||||
this._seat = this._display.get_default_seat();
|
||||
this._pointer = this._seat.get_pointer();
|
||||
} catch (e) {
|
||||
this.destroy();
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Try to read modifier keycodes from Gdk
|
||||
try {
|
||||
const keymap = Gdk.Keymap.get_for_display(this._display);
|
||||
let modifier;
|
||||
|
||||
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Alt_L)[1][0];
|
||||
XKeycode.Alt_L = modifier.keycode;
|
||||
|
||||
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Control_L)[1][0];
|
||||
XKeycode.Control_L = modifier.keycode;
|
||||
|
||||
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Shift_L)[1][0];
|
||||
XKeycode.Shift_L = modifier.keycode;
|
||||
|
||||
modifier = keymap.get_entries_for_keyval(Gdk.KEY_Super_L)[1][0];
|
||||
XKeycode.Super_L = modifier.keycode;
|
||||
} catch (e) {
|
||||
debug('using default modifier keycodes');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Pointer events
|
||||
*/
|
||||
clickPointer(button) {
|
||||
try {
|
||||
const [, x, y] = this._pointer.get_position();
|
||||
const monitor = this._display.get_monitor_at_point(x, y);
|
||||
const scale = monitor.get_scale_factor();
|
||||
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}c`);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
doubleclickPointer(button) {
|
||||
try {
|
||||
const [, x, y] = this._pointer.get_position();
|
||||
const monitor = this._display.get_monitor_at_point(x, y);
|
||||
const scale = monitor.get_scale_factor();
|
||||
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}d`);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
movePointer(dx, dy) {
|
||||
try {
|
||||
const [, x, y] = this._pointer.get_position();
|
||||
const monitor = this._display.get_monitor_at_point(x, y);
|
||||
const scale = monitor.get_scale_factor();
|
||||
Atspi.generate_mouse_event(scale * dx, scale * dy, 'rel');
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
pressPointer(button) {
|
||||
try {
|
||||
const [, x, y] = this._pointer.get_position();
|
||||
const monitor = this._display.get_monitor_at_point(x, y);
|
||||
const scale = monitor.get_scale_factor();
|
||||
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}p`);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
releasePointer(button) {
|
||||
try {
|
||||
const [, x, y] = this._pointer.get_position();
|
||||
const monitor = this._display.get_monitor_at_point(x, y);
|
||||
const scale = monitor.get_scale_factor();
|
||||
Atspi.generate_mouse_event(scale * x, scale * y, `b${button}r`);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
scrollPointer(dx, dy) {
|
||||
if (dy > 0)
|
||||
this.clickPointer(4);
|
||||
else if (dy < 0)
|
||||
this.clickPointer(5);
|
||||
}
|
||||
|
||||
/*
|
||||
* Phony virtual keyboard helpers
|
||||
*/
|
||||
_modeLock(keycode) {
|
||||
Atspi.generate_keyboard_event(
|
||||
keycode,
|
||||
null,
|
||||
Atspi.KeySynthType.PRESS
|
||||
);
|
||||
}
|
||||
|
||||
_modeUnlock(keycode) {
|
||||
Atspi.generate_keyboard_event(
|
||||
keycode,
|
||||
null,
|
||||
Atspi.KeySynthType.RELEASE
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Simulate a printable-ASCII character.
|
||||
*
|
||||
*/
|
||||
_pressASCII(key, modifiers) {
|
||||
try {
|
||||
// Press Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this._modeLock(XKeycode.Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this._modeLock(XKeycode.Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this._modeLock(XKeycode.Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this._modeLock(XKeycode.Super_L);
|
||||
|
||||
Atspi.generate_keyboard_event(
|
||||
0,
|
||||
key,
|
||||
Atspi.KeySynthType.STRING
|
||||
);
|
||||
|
||||
// Release Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this._modeUnlock(XKeycode.Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this._modeUnlock(XKeycode.Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this._modeUnlock(XKeycode.Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this._modeUnlock(XKeycode.Super_L);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_pressKeysym(keysym, modifiers) {
|
||||
try {
|
||||
// Press Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this._modeLock(XKeycode.Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this._modeLock(XKeycode.Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this._modeLock(XKeycode.Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this._modeLock(XKeycode.Super_L);
|
||||
|
||||
Atspi.generate_keyboard_event(
|
||||
keysym,
|
||||
null,
|
||||
Atspi.KeySynthType.PRESSRELEASE | Atspi.KeySynthType.SYM
|
||||
);
|
||||
|
||||
// Release Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this._modeUnlock(XKeycode.Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this._modeUnlock(XKeycode.Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this._modeUnlock(XKeycode.Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this._modeUnlock(XKeycode.Super_L);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the composition of a unicode character with:
|
||||
* Control+Shift+u, [hex], Return
|
||||
*
|
||||
* @param {number} key - An XKeycode
|
||||
* @param {number} modifiers - A modifier mask
|
||||
*/
|
||||
_pressUnicode(key, modifiers) {
|
||||
try {
|
||||
if (modifiers > 0)
|
||||
log('GSConnect: ignoring modifiers for unicode keyboard event');
|
||||
|
||||
// TODO: Using Control and Shift keysym is not working (it triggers
|
||||
// key release). Probably using LOCKMODIFIERS will not work either
|
||||
// as unlocking the modifier will not trigger a release
|
||||
|
||||
// Activate compose sequence
|
||||
this._modeLock(XKeycode.Control_L);
|
||||
this._modeLock(XKeycode.Shift_L);
|
||||
|
||||
this.pressreleaseKeysym(Gdk.KEY_U);
|
||||
|
||||
this._modeUnlock(XKeycode.Control_L);
|
||||
this._modeUnlock(XKeycode.Shift_L);
|
||||
|
||||
// Enter the unicode sequence
|
||||
const ucode = key.charCodeAt(0).toString(16);
|
||||
let keysym;
|
||||
|
||||
for (let h = 0, len = ucode.length; h < len; h++) {
|
||||
keysym = Gdk.unicode_to_keyval(ucode.charAt(h).codePointAt(0));
|
||||
this.pressreleaseKeysym(keysym);
|
||||
}
|
||||
|
||||
// Finish the compose sequence
|
||||
this.pressreleaseKeysym(Gdk.KEY_Return);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Keyboard Events
|
||||
*/
|
||||
pressKeysym(keysym) {
|
||||
Atspi.generate_keyboard_event(
|
||||
keysym,
|
||||
null,
|
||||
Atspi.KeySynthType.PRESS | Atspi.KeySynthType.SYM
|
||||
);
|
||||
}
|
||||
|
||||
releaseKeysym(keysym) {
|
||||
Atspi.generate_keyboard_event(
|
||||
keysym,
|
||||
null,
|
||||
Atspi.KeySynthType.RELEASE | Atspi.KeySynthType.SYM
|
||||
);
|
||||
}
|
||||
|
||||
pressreleaseKeysym(keysym) {
|
||||
Atspi.generate_keyboard_event(
|
||||
keysym,
|
||||
null,
|
||||
Atspi.KeySynthType.PRESSRELEASE | Atspi.KeySynthType.SYM
|
||||
);
|
||||
}
|
||||
|
||||
pressKey(input, modifiers) {
|
||||
// We were passed a keysym
|
||||
if (typeof input === 'number')
|
||||
this._pressKeysym(input, modifiers);
|
||||
|
||||
// Regular ASCII
|
||||
else if (_ASCII.test(input))
|
||||
this._pressASCII(input, modifiers);
|
||||
|
||||
// Unicode
|
||||
else
|
||||
this._pressUnicode(input, modifiers);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
Atspi.exit();
|
||||
} catch (e) {
|
||||
// Silence errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const GLib = imports.gi.GLib;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
|
||||
const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
|
||||
const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
|
||||
|
||||
|
||||
var Clipboard = GObject.registerClass({
|
||||
GTypeName: 'GSConnectClipboard',
|
||||
Properties: {
|
||||
'text': GObject.ParamSpec.string(
|
||||
'text',
|
||||
'Text Content',
|
||||
'The current text content of the clipboard',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
''
|
||||
),
|
||||
},
|
||||
}, class Clipboard extends GObject.Object {
|
||||
|
||||
_init() {
|
||||
super._init();
|
||||
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
this._clipboard = null;
|
||||
|
||||
this._ownerChangeId = 0;
|
||||
this._nameWatcherId = Gio.bus_watch_name(
|
||||
Gio.BusType.SESSION,
|
||||
DBUS_NAME,
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
this._onNameAppeared.bind(this),
|
||||
this._onNameVanished.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get text() {
|
||||
if (this._text === undefined)
|
||||
this._text = '';
|
||||
|
||||
return this._text;
|
||||
}
|
||||
|
||||
set text(content) {
|
||||
if (this.text === content)
|
||||
return;
|
||||
|
||||
this._text = content;
|
||||
this.notify('text');
|
||||
|
||||
if (typeof content !== 'string')
|
||||
return;
|
||||
|
||||
if (this._clipboard instanceof Gtk.Clipboard)
|
||||
this._clipboard.set_text(content, -1);
|
||||
|
||||
if (this._clipboard instanceof Gio.DBusProxy)
|
||||
this._proxySetText(content);
|
||||
}
|
||||
|
||||
async _onNameAppeared(connection, name, name_owner) {
|
||||
try {
|
||||
// Cleanup the GtkClipboard
|
||||
if (this._clipboard && this._ownerChangeId > 0) {
|
||||
this._clipboard.disconnect(this._ownerChangeId);
|
||||
this._ownerChangeId = 0;
|
||||
}
|
||||
|
||||
// Create a proxy for the remote clipboard
|
||||
this._clipboard = new Gio.DBusProxy({
|
||||
g_bus_type: Gio.BusType.SESSION,
|
||||
g_name: DBUS_NAME,
|
||||
g_object_path: DBUS_PATH,
|
||||
g_interface_name: DBUS_NAME,
|
||||
g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this._clipboard.init_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this._cancellable,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
resolve(proxy.init_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this._ownerChangeId = this._clipboard.connect(
|
||||
'g-signal',
|
||||
this._onOwnerChange.bind(this)
|
||||
);
|
||||
|
||||
this._onOwnerChange();
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
|
||||
debug(e);
|
||||
this._onNameVanished(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onNameVanished(connection, name) {
|
||||
if (this._clipboard && this._ownerChangeId > 0) {
|
||||
this._clipboard.disconnect(this._ownerChangeId);
|
||||
this._clipboardChangedId = 0;
|
||||
}
|
||||
|
||||
const display = Gdk.Display.get_default();
|
||||
this._clipboard = Gtk.Clipboard.get_default(display);
|
||||
|
||||
this._ownerChangeId = this._clipboard.connect(
|
||||
'owner-change',
|
||||
this._onOwnerChange.bind(this)
|
||||
);
|
||||
|
||||
this._onOwnerChange();
|
||||
}
|
||||
|
||||
async _onOwnerChange() {
|
||||
try {
|
||||
if (this._clipboard instanceof Gtk.Clipboard)
|
||||
await this._gtkUpdateText();
|
||||
|
||||
else if (this._clipboard instanceof Gio.DBusProxy)
|
||||
await this._proxyUpdateText();
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
_applyUpdate(text) {
|
||||
if (typeof text !== 'string' || this.text === text)
|
||||
return;
|
||||
|
||||
this._text = text;
|
||||
this.notify('text');
|
||||
}
|
||||
|
||||
/*
|
||||
* Proxy Clipboard
|
||||
*/
|
||||
_proxyGetMimetypes() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._clipboard.call(
|
||||
'GetMimetypes',
|
||||
null,
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
this._cancellable,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
const reply = proxy.call_finish(res);
|
||||
resolve(reply.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
Gio.DBusError.strip_remote_error(e);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_proxyGetText() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._clipboard.call(
|
||||
'GetText',
|
||||
null,
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
this._cancellable,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
const reply = proxy.call_finish(res);
|
||||
resolve(reply.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
Gio.DBusError.strip_remote_error(e);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_proxySetText(text) {
|
||||
this._clipboard.call(
|
||||
'SetText',
|
||||
new GLib.Variant('(s)', [text]),
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
this._cancellable,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
proxy.call_finish(res);
|
||||
} catch (e) {
|
||||
Gio.DBusError.strip_remote_error(e);
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async _proxyUpdateText() {
|
||||
const mimetypes = await this._proxyGetMimetypes();
|
||||
|
||||
// Special case for a cleared clipboard
|
||||
if (mimetypes.length === 0)
|
||||
return this._applyUpdate('');
|
||||
|
||||
// Special case to ignore copied files
|
||||
if (mimetypes.includes('text/uri-list'))
|
||||
return;
|
||||
|
||||
const text = await this._proxyGetText();
|
||||
|
||||
this._applyUpdate(text);
|
||||
}
|
||||
|
||||
/*
|
||||
* GtkClipboard
|
||||
*/
|
||||
_gtkGetMimetypes() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._clipboard.request_targets((clipboard, atoms) => resolve(atoms));
|
||||
});
|
||||
}
|
||||
|
||||
_gtkGetText() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._clipboard.request_text((clipboard, text) => resolve(text));
|
||||
});
|
||||
}
|
||||
|
||||
async _gtkUpdateText() {
|
||||
const mimetypes = await this._gtkGetMimetypes();
|
||||
|
||||
// Special case for a cleared clipboard
|
||||
if (mimetypes.length === 0)
|
||||
return this._applyUpdate('');
|
||||
|
||||
// Special case to ignore copied files
|
||||
if (mimetypes.includes('text/uri-list'))
|
||||
return;
|
||||
|
||||
const text = await this._gtkGetText();
|
||||
|
||||
this._applyUpdate(text);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._cancellable.is_cancelled())
|
||||
return;
|
||||
|
||||
this._cancellable.cancel();
|
||||
|
||||
if (this._clipboard && this._ownerChangeId > 0) {
|
||||
this._clipboard.disconnect(this._ownerChangeId);
|
||||
this._ownerChangedId = 0;
|
||||
}
|
||||
|
||||
if (this._nameWatcherId > 0) {
|
||||
Gio.bus_unwatch_name(this._nameWatcherId);
|
||||
this._nameWatcherId = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Clipboard;
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
'use strict';
|
||||
|
||||
const ByteArray = imports.byteArray;
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
var HAVE_EDS = true;
|
||||
var EBook = null;
|
||||
var EBookContacts = null;
|
||||
var EDataServer = null;
|
||||
|
||||
try {
|
||||
EBook = imports.gi.EBook;
|
||||
EBookContacts = imports.gi.EBookContacts;
|
||||
EDataServer = imports.gi.EDataServer;
|
||||
} catch (e) {
|
||||
HAVE_EDS = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A store for contacts
|
||||
*/
|
||||
var Store = GObject.registerClass({
|
||||
GTypeName: 'GSConnectContactsStore',
|
||||
Properties: {
|
||||
'context': GObject.ParamSpec.string(
|
||||
'context',
|
||||
'Context',
|
||||
'Used as the cache directory, relative to Config.CACHEDIR',
|
||||
GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
},
|
||||
Signals: {
|
||||
'contact-added': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
param_types: [GObject.TYPE_STRING],
|
||||
},
|
||||
'contact-removed': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
param_types: [GObject.TYPE_STRING],
|
||||
},
|
||||
'contact-changed': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
param_types: [GObject.TYPE_STRING],
|
||||
},
|
||||
},
|
||||
}, class Store extends GObject.Object {
|
||||
|
||||
_init(context = null) {
|
||||
super._init({
|
||||
context: context,
|
||||
});
|
||||
|
||||
this._cacheData = {};
|
||||
this._edsPrepared = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an EContact and add it to the store.
|
||||
*
|
||||
* @param {EBookContacts.Contact} econtact - an EContact to parse
|
||||
* @param {string} [origin] - an optional origin string
|
||||
*/
|
||||
async _parseEContact(econtact, origin = 'desktop') {
|
||||
try {
|
||||
const contact = {
|
||||
id: econtact.id,
|
||||
name: _('Unknown Contact'),
|
||||
numbers: [],
|
||||
origin: origin,
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
// Try to get a contact name
|
||||
if (econtact.full_name)
|
||||
contact.name = econtact.full_name;
|
||||
|
||||
// Parse phone numbers
|
||||
const nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
|
||||
|
||||
for (const attr of nums) {
|
||||
const number = {
|
||||
value: attr.get_value(),
|
||||
type: 'unknown',
|
||||
};
|
||||
|
||||
if (attr.has_type('CELL'))
|
||||
number.type = 'cell';
|
||||
else if (attr.has_type('HOME'))
|
||||
number.type = 'home';
|
||||
else if (attr.has_type('WORK'))
|
||||
number.type = 'work';
|
||||
|
||||
contact.numbers.push(number);
|
||||
}
|
||||
|
||||
// Try and get a contact photo
|
||||
const photo = econtact.photo;
|
||||
|
||||
if (photo) {
|
||||
if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
|
||||
const data = photo.get_inlined()[0];
|
||||
contact.avatar = await this.storeAvatar(data);
|
||||
|
||||
} else if (photo.type === EBookContacts.ContactPhotoType.URI) {
|
||||
const uri = econtact.photo.get_uri();
|
||||
contact.avatar = uri.replace('file://', '');
|
||||
}
|
||||
}
|
||||
|
||||
this.add(contact, false);
|
||||
} catch (e) {
|
||||
logError(e, `Failed to parse VCard contact ${econtact.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* EDS Helpers
|
||||
*/
|
||||
_getEBookClient(source, cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
EBook.BookClient.connect(source, 0, cancellable, (source, res) => {
|
||||
try {
|
||||
resolve(EBook.BookClient.connect_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_getEBookView(client, query = '', cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.get_view(query, cancellable, (client, res) => {
|
||||
try {
|
||||
resolve(client.get_view_finish(res)[1]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_getEContacts(client, query = '', cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.get_contacts(query, cancellable, (client, res) => {
|
||||
try {
|
||||
resolve(client.get_contacts_finish(res)[1]);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_getESourceRegistry(cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
EDataServer.SourceRegistry.new(cancellable, (registry, res) => {
|
||||
try {
|
||||
resolve(EDataServer.SourceRegistry.new_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* AddressBook DBus callbacks
|
||||
*/
|
||||
_onObjectsAdded(connection, sender, path, iface, signal, params) {
|
||||
try {
|
||||
const adds = params.get_child_value(0).get_strv();
|
||||
|
||||
// NOTE: sequential pairs of vcard, id
|
||||
for (let i = 0, len = adds.length; i < len; i += 2) {
|
||||
try {
|
||||
const vcard = adds[i];
|
||||
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
||||
this._parseEContact(econtact);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onObjectsRemoved(connection, sender, path, iface, signal, params) {
|
||||
try {
|
||||
const changes = params.get_child_value(0).get_strv();
|
||||
|
||||
for (const id of changes) {
|
||||
try {
|
||||
this.remove(id, false);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onObjectsModified(connection, sender, path, iface, signal, params) {
|
||||
try {
|
||||
const changes = params.get_child_value(0).get_strv();
|
||||
|
||||
// NOTE: sequential pairs of vcard, id
|
||||
for (let i = 0, len = changes.length; i < len; i += 2) {
|
||||
try {
|
||||
const vcard = changes[i];
|
||||
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
||||
this._parseEContact(econtact);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* SourceRegistryWatcher callbacks
|
||||
*/
|
||||
async _onAppeared(watcher, source) {
|
||||
try {
|
||||
// Get an EBookClient and EBookView
|
||||
const uid = source.get_uid();
|
||||
const client = await this._getEBookClient(source);
|
||||
const view = await this._getEBookView(client, 'exists "tel"');
|
||||
|
||||
// Watch the view for changes to the address book
|
||||
const connection = view.get_connection();
|
||||
const objectPath = view.get_object_path();
|
||||
|
||||
view._objectsAddedId = connection.signal_subscribe(
|
||||
null,
|
||||
'org.gnome.evolution.dataserver.AddressBookView',
|
||||
'ObjectsAdded',
|
||||
objectPath,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
this._onObjectsAdded.bind(this)
|
||||
);
|
||||
|
||||
view._objectsRemovedId = connection.signal_subscribe(
|
||||
null,
|
||||
'org.gnome.evolution.dataserver.AddressBookView',
|
||||
'ObjectsRemoved',
|
||||
objectPath,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
this._onObjectsRemoved.bind(this)
|
||||
);
|
||||
|
||||
view._objectsModifiedId = connection.signal_subscribe(
|
||||
null,
|
||||
'org.gnome.evolution.dataserver.AddressBookView',
|
||||
'ObjectsModified',
|
||||
objectPath,
|
||||
null,
|
||||
Gio.DBusSignalFlags.NONE,
|
||||
this._onObjectsModified.bind(this)
|
||||
);
|
||||
|
||||
view.start();
|
||||
|
||||
// Store the EBook in a map
|
||||
this._ebooks.set(uid, {
|
||||
source: source,
|
||||
client: client,
|
||||
view: view,
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onDisappeared(watcher, source) {
|
||||
try {
|
||||
const uid = source.get_uid();
|
||||
const ebook = this._ebooks.get(uid);
|
||||
|
||||
if (ebook === undefined)
|
||||
return;
|
||||
|
||||
// Disconnect the EBookView
|
||||
if (ebook.view) {
|
||||
const connection = ebook.view.get_connection();
|
||||
connection.signal_unsubscribe(ebook.view._objectsAddedId);
|
||||
connection.signal_unsubscribe(ebook.view._objectsRemovedId);
|
||||
connection.signal_unsubscribe(ebook.view._objectsModifiedId);
|
||||
|
||||
ebook.view.stop();
|
||||
}
|
||||
|
||||
this._ebooks.delete(uid);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
async _initEvolutionDataServer() {
|
||||
try {
|
||||
if (this._edsPrepared)
|
||||
return;
|
||||
|
||||
this._edsPrepared = true;
|
||||
this._ebooks = new Map();
|
||||
|
||||
// Get the current EBooks
|
||||
const registry = await this._getESourceRegistry();
|
||||
|
||||
for (const source of registry.list_sources('Address Book'))
|
||||
await this._onAppeared(null, source);
|
||||
|
||||
// Watch for new and removed sources
|
||||
this._watcher = new EDataServer.SourceRegistryWatcher({
|
||||
registry: registry,
|
||||
extension_name: 'Address Book',
|
||||
});
|
||||
|
||||
this._appearedId = this._watcher.connect(
|
||||
'appeared',
|
||||
this._onAppeared.bind(this)
|
||||
);
|
||||
this._disappearedId = this._watcher.connect(
|
||||
'disappeared',
|
||||
this._onDisappeared.bind(this)
|
||||
);
|
||||
} catch (e) {
|
||||
const service = Gio.Application.get_default();
|
||||
|
||||
if (service !== null)
|
||||
service.notify_error(e);
|
||||
else
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
const contacts = Object.values(this._cacheData);
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++)
|
||||
yield contacts[i];
|
||||
}
|
||||
|
||||
get contacts() {
|
||||
return Object.values(this._cacheData);
|
||||
}
|
||||
|
||||
get context() {
|
||||
if (this._context === undefined)
|
||||
this._context = null;
|
||||
|
||||
return this._context;
|
||||
}
|
||||
|
||||
set context(context) {
|
||||
this._context = context;
|
||||
this._cacheDir = Gio.File.new_for_path(Config.CACHEDIR);
|
||||
|
||||
if (context !== null)
|
||||
this._cacheDir = this._cacheDir.get_child(context);
|
||||
|
||||
GLib.mkdir_with_parents(this._cacheDir.get_path(), 448);
|
||||
this._cacheFile = this._cacheDir.get_child('contacts.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a ByteArray to file and return the path
|
||||
*
|
||||
* @param {ByteArray} contents - An image ByteArray
|
||||
* @return {string|undefined} File path or %undefined on failure
|
||||
*/
|
||||
storeAvatar(contents) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const md5 = GLib.compute_checksum_for_data(
|
||||
GLib.ChecksumType.MD5,
|
||||
contents
|
||||
);
|
||||
const file = this._cacheDir.get_child(`${md5}`);
|
||||
|
||||
if (file.query_exists(null)) {
|
||||
resolve(file.get_path());
|
||||
} else {
|
||||
file.replace_contents_bytes_async(
|
||||
new GLib.Bytes(contents),
|
||||
null,
|
||||
false,
|
||||
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
||||
null,
|
||||
(file, res) => {
|
||||
try {
|
||||
file.replace_contents_finish(res);
|
||||
resolve(file.get_path());
|
||||
} catch (e) {
|
||||
debug(e, 'Storing avatar');
|
||||
resolve(undefined);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the Store for a contact by name and/or number.
|
||||
*
|
||||
* @param {Object} query - A query object
|
||||
* @param {string} [query.name] - The contact's name
|
||||
* @param {string} query.number - The contact's number
|
||||
* @return {Object} A contact object
|
||||
*/
|
||||
query(query) {
|
||||
// First look for an existing contact by number
|
||||
const contacts = this.contacts;
|
||||
const matches = [];
|
||||
const qnumber = query.number.toPhoneNumber();
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++) {
|
||||
const contact = contacts[i];
|
||||
|
||||
for (const num of contact.numbers) {
|
||||
const cnumber = num.value.toPhoneNumber();
|
||||
|
||||
if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) {
|
||||
// If no query name or exact match, return immediately
|
||||
if (!query.name || query.name === contact.name)
|
||||
return contact;
|
||||
|
||||
// Otherwise we might find an exact name match that shares
|
||||
// the number with another contact
|
||||
matches.push(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first match (pretty much what Android does)
|
||||
if (matches.length > 0)
|
||||
return matches[0];
|
||||
|
||||
// No match; return a mock contact with a unique ID
|
||||
let id = GLib.uuid_string_random();
|
||||
|
||||
while (this._cacheData.hasOwnProperty(id))
|
||||
id = GLib.uuid_string_random();
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: query.name || query.number,
|
||||
numbers: [{value: query.number, type: 'unknown'}],
|
||||
origin: 'gsconnect',
|
||||
};
|
||||
}
|
||||
|
||||
get_contact(position) {
|
||||
if (this._cacheData[position] !== undefined)
|
||||
return this._cacheData[position];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a contact, checking for validity
|
||||
*
|
||||
* @param {Object} contact - A contact object
|
||||
* @param {boolean} write - Write to disk
|
||||
*/
|
||||
add(contact, write = true) {
|
||||
// Ensure the contact has a unique id
|
||||
if (!contact.id) {
|
||||
let id = GLib.uuid_string_random();
|
||||
|
||||
while (this._cacheData[id])
|
||||
id = GLib.uuid_string_random();
|
||||
|
||||
contact.id = id;
|
||||
}
|
||||
|
||||
// Ensure the contact has an origin
|
||||
if (!contact.origin)
|
||||
contact.origin = 'gsconnect';
|
||||
|
||||
// This is an updated contact
|
||||
if (this._cacheData[contact.id]) {
|
||||
this._cacheData[contact.id] = contact;
|
||||
this.emit('contact-changed', contact.id);
|
||||
|
||||
// This is a new contact
|
||||
} else {
|
||||
this._cacheData[contact.id] = contact;
|
||||
this.emit('contact-added', contact.id);
|
||||
}
|
||||
|
||||
// Write if requested
|
||||
if (write)
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a contact by id
|
||||
*
|
||||
* @param {string} id - The id of the contact to delete
|
||||
* @param {boolean} write - Write to disk
|
||||
*/
|
||||
remove(id, write = true) {
|
||||
// Only remove if the contact actually exists
|
||||
if (this._cacheData[id]) {
|
||||
delete this._cacheData[id];
|
||||
this.emit('contact-removed', id);
|
||||
|
||||
// Write if requested
|
||||
if (write)
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a contact for each address object in @addresses and return a
|
||||
* dictionary of address (eg. phone number) to contact object.
|
||||
*
|
||||
* { "555-5555": { "name": "...", "numbers": [], ... } }
|
||||
*
|
||||
* @param {Object[]} addresses - A list of address objects
|
||||
* @return {Object} A dictionary of phone numbers and contacts
|
||||
*/
|
||||
lookupAddresses(addresses) {
|
||||
const contacts = {};
|
||||
|
||||
// Lookup contacts for each address
|
||||
for (let i = 0, len = addresses.length; i < len; i++) {
|
||||
const address = addresses[i].address;
|
||||
|
||||
contacts[address] = this.query({
|
||||
number: address,
|
||||
});
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
const contacts = this.contacts;
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++)
|
||||
await this.remove(contacts[i].id, false);
|
||||
|
||||
await this.save();
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the contact store from a dictionary of our custom contact objects.
|
||||
*
|
||||
* @param {Object} json - an Object of contact Objects
|
||||
*/
|
||||
async update(json = {}) {
|
||||
try {
|
||||
let contacts = Object.values(json);
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++) {
|
||||
const new_contact = contacts[i];
|
||||
const contact = this._cacheData[new_contact.id];
|
||||
|
||||
if (!contact || new_contact.timestamp !== contact.timestamp)
|
||||
await this.add(new_contact, false);
|
||||
}
|
||||
|
||||
// Prune contacts
|
||||
contacts = this.contacts;
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++) {
|
||||
const contact = contacts[i];
|
||||
|
||||
if (!json[contact.id])
|
||||
await this.remove(contact.id, false);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
} catch (e) {
|
||||
debug(e, 'Updating contacts');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and update the contact store from its source.
|
||||
*
|
||||
* The default function initializes the EDS server, or logs a debug message
|
||||
* if EDS is unavailable. Derived classes should request an update from the
|
||||
* remote source.
|
||||
*/
|
||||
async fetch() {
|
||||
try {
|
||||
if (this.context === null && HAVE_EDS)
|
||||
await this._initEvolutionDataServer();
|
||||
else
|
||||
throw new Error('Evolution Data Server not available');
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the contacts from disk.
|
||||
*/
|
||||
async load() {
|
||||
try {
|
||||
this._cacheData = await new Promise((resolve, reject) => {
|
||||
this._cacheFile.load_contents_async(null, (file, res) => {
|
||||
try {
|
||||
const contents = file.load_contents_finish(res)[1];
|
||||
|
||||
resolve(JSON.parse(ByteArray.toString(contents)));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
} finally {
|
||||
this.notify('context');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the contacts to disk.
|
||||
*/
|
||||
async save() {
|
||||
// EDS is handling storage
|
||||
if (this.context === null && HAVE_EDS)
|
||||
return;
|
||||
|
||||
if (this.__cache_lock) {
|
||||
this.__cache_queue = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.__cache_lock = true;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this._cacheFile.replace_contents_bytes_async(
|
||||
new GLib.Bytes(JSON.stringify(this._cacheData, null, 2)),
|
||||
null,
|
||||
false,
|
||||
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
||||
null,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.replace_contents_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
} finally {
|
||||
this.__cache_lock = false;
|
||||
|
||||
if (this.__cache_queue) {
|
||||
this.__cache_queue = false;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._watcher !== undefined) {
|
||||
this._watcher.disconnect(this._appearedId);
|
||||
this._watcher.disconnect(this._disappearedId);
|
||||
this._watcher = undefined;
|
||||
|
||||
for (const ebook of this._ebooks.values())
|
||||
this._onDisappeared(null, ebook.source);
|
||||
|
||||
this._edsPrepared = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Store;
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
|
||||
const SESSION_TIMEOUT = 15;
|
||||
|
||||
|
||||
const RemoteSession = GObject.registerClass({
|
||||
GTypeName: 'GSConnectRemoteSession',
|
||||
Implements: [Gio.DBusInterface],
|
||||
Signals: {
|
||||
'closed': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
},
|
||||
},
|
||||
}, class RemoteSession extends Gio.DBusProxy {
|
||||
|
||||
_init(objectPath) {
|
||||
super._init({
|
||||
g_bus_type: Gio.BusType.SESSION,
|
||||
g_name: 'org.gnome.Mutter.RemoteDesktop',
|
||||
g_object_path: objectPath,
|
||||
g_interface_name: 'org.gnome.Mutter.RemoteDesktop.Session',
|
||||
g_flags: Gio.DBusProxyFlags.NONE,
|
||||
});
|
||||
|
||||
this._started = false;
|
||||
}
|
||||
|
||||
vfunc_g_signal(sender_name, signal_name, parameters) {
|
||||
if (signal_name === 'Closed')
|
||||
this.emit('closed');
|
||||
}
|
||||
|
||||
_call(name, parameters = null) {
|
||||
if (!this._started)
|
||||
return;
|
||||
|
||||
this.call(name, parameters, Gio.DBusCallFlags.NONE, -1, null, null);
|
||||
}
|
||||
|
||||
get session_id() {
|
||||
try {
|
||||
return this.get_cached_property('SessionId').unpack();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
if (this._started)
|
||||
return;
|
||||
|
||||
// Initialize the proxy
|
||||
await new Promise((resolve, reject) => {
|
||||
this.init_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
proxy.init_finish(res);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Start the session
|
||||
await new Promise((resolve, reject) => {
|
||||
this.call(
|
||||
'Start',
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
resolve(proxy.call_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this._started = true;
|
||||
} catch (e) {
|
||||
this.destroy();
|
||||
|
||||
Gio.DBusError.strip_remote_error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._started) {
|
||||
this._started = false;
|
||||
this.call('Stop', null, Gio.DBusCallFlags.NONE, -1, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
_translateButton(button) {
|
||||
switch (button) {
|
||||
case Gdk.BUTTON_PRIMARY:
|
||||
return 0x110;
|
||||
|
||||
case Gdk.BUTTON_MIDDLE:
|
||||
return 0x112;
|
||||
|
||||
case Gdk.BUTTON_SECONDARY:
|
||||
return 0x111;
|
||||
|
||||
case 4:
|
||||
return 0; // FIXME
|
||||
|
||||
case 5:
|
||||
return 0x10F; // up
|
||||
}
|
||||
}
|
||||
|
||||
movePointer(dx, dy) {
|
||||
this._call(
|
||||
'NotifyPointerMotionRelative',
|
||||
GLib.Variant.new('(dd)', [dx, dy])
|
||||
);
|
||||
}
|
||||
|
||||
pressPointer(button) {
|
||||
button = this._translateButton(button);
|
||||
|
||||
this._call(
|
||||
'NotifyPointerButton',
|
||||
GLib.Variant.new('(ib)', [button, true])
|
||||
);
|
||||
}
|
||||
|
||||
releasePointer(button) {
|
||||
button = this._translateButton(button);
|
||||
|
||||
this._call(
|
||||
'NotifyPointerButton',
|
||||
GLib.Variant.new('(ib)', [button, false])
|
||||
);
|
||||
}
|
||||
|
||||
clickPointer(button) {
|
||||
button = this._translateButton(button);
|
||||
|
||||
this._call(
|
||||
'NotifyPointerButton',
|
||||
GLib.Variant.new('(ib)', [button, true])
|
||||
);
|
||||
|
||||
this._call(
|
||||
'NotifyPointerButton',
|
||||
GLib.Variant.new('(ib)', [button, false])
|
||||
);
|
||||
}
|
||||
|
||||
doubleclickPointer(button) {
|
||||
this.clickPointer(button);
|
||||
this.clickPointer(button);
|
||||
}
|
||||
|
||||
scrollPointer(dx, dy) {
|
||||
// NOTE: NotifyPointerAxis only seems to work on Wayland, but maybe
|
||||
// NotifyPointerAxisDiscrete is the better choice anyways
|
||||
if (HAVE_WAYLAND) {
|
||||
this._call(
|
||||
'NotifyPointerAxis',
|
||||
GLib.Variant.new('(ddu)', [dx, dy, 0])
|
||||
);
|
||||
this._call(
|
||||
'NotifyPointerAxis',
|
||||
GLib.Variant.new('(ddu)', [0, 0, 1])
|
||||
);
|
||||
} else if (dy > 0) {
|
||||
this._call(
|
||||
'NotifyPointerAxisDiscrete',
|
||||
GLib.Variant.new('(ui)', [Gdk.ScrollDirection.UP, 1])
|
||||
);
|
||||
} else if (dy < 0) {
|
||||
this._call(
|
||||
'NotifyPointerAxisDiscrete',
|
||||
GLib.Variant.new('(ui)', [Gdk.ScrollDirection.UP, -1])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Keyboard Events
|
||||
*/
|
||||
pressKeysym(keysym) {
|
||||
this._call(
|
||||
'NotifyKeyboardKeysym',
|
||||
GLib.Variant.new('(ub)', [keysym, true])
|
||||
);
|
||||
}
|
||||
|
||||
releaseKeysym(keysym) {
|
||||
this._call(
|
||||
'NotifyKeyboardKeysym',
|
||||
GLib.Variant.new('(ub)', [keysym, false])
|
||||
);
|
||||
}
|
||||
|
||||
pressreleaseKeysym(keysym) {
|
||||
this._call(
|
||||
'NotifyKeyboardKeysym',
|
||||
GLib.Variant.new('(ub)', [keysym, true])
|
||||
);
|
||||
this._call(
|
||||
'NotifyKeyboardKeysym',
|
||||
GLib.Variant.new('(ub)', [keysym, false])
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* High-level keyboard input
|
||||
*/
|
||||
pressKey(input, modifiers) {
|
||||
// Press Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this.pressKeysym(Gdk.KEY_Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this.pressKeysym(Gdk.KEY_Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this.pressKeysym(Gdk.KEY_Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this.pressKeysym(Gdk.KEY_Super_L);
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const keysym = Gdk.unicode_to_keyval(input.codePointAt(0));
|
||||
this.pressreleaseKeysym(keysym);
|
||||
} else {
|
||||
this.pressreleaseKeysym(input);
|
||||
}
|
||||
|
||||
// Release Modifiers
|
||||
if (modifiers & Gdk.ModifierType.MOD1_MASK)
|
||||
this.releaseKeysym(Gdk.KEY_Alt_L);
|
||||
if (modifiers & Gdk.ModifierType.CONTROL_MASK)
|
||||
this.releaseKeysym(Gdk.KEY_Control_L);
|
||||
if (modifiers & Gdk.ModifierType.SHIFT_MASK)
|
||||
this.releaseKeysym(Gdk.KEY_Shift_L);
|
||||
if (modifiers & Gdk.ModifierType.SUPER_MASK)
|
||||
this.releaseKeysym(Gdk.KEY_Super_L);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.__disposed === undefined) {
|
||||
this.__disposed = true;
|
||||
GObject.signal_handlers_destroy(this);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
class Controller {
|
||||
constructor() {
|
||||
this._nameAppearedId = 0;
|
||||
this._session = null;
|
||||
this._sessionCloseId = 0;
|
||||
this._sessionExpiry = 0;
|
||||
this._sessionExpiryId = 0;
|
||||
this._sessionStarting = false;
|
||||
|
||||
// Watch for the RemoteDesktop portal
|
||||
this._nameWatcherId = Gio.bus_watch_name(
|
||||
Gio.BusType.SESSION,
|
||||
'org.gnome.Mutter.RemoteDesktop',
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
this._onNameAppeared.bind(this),
|
||||
this._onNameVanished.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get connection() {
|
||||
if (this._connection === undefined)
|
||||
this._connection = null;
|
||||
|
||||
return this._connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a Wayland session, specifically for distributions that
|
||||
* don't ship pipewire support (eg. Debian/Ubuntu).
|
||||
*
|
||||
* FIXME: this is a super ugly hack that should go away
|
||||
*
|
||||
* @return {boolean} %true if wayland is not supported
|
||||
*/
|
||||
_checkWayland() {
|
||||
if (HAVE_WAYLAND) {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
HAVE_REMOTEINPUT = false;
|
||||
const service = Gio.Application.get_default();
|
||||
|
||||
if (service === null)
|
||||
return true;
|
||||
|
||||
// First we're going to disabled the affected plugins on all devices
|
||||
for (const device of service.manager.devices.values()) {
|
||||
const supported = device.settings.get_strv('supported-plugins');
|
||||
let index;
|
||||
|
||||
if ((index = supported.indexOf('mousepad')) > -1)
|
||||
supported.splice(index, 1);
|
||||
|
||||
if ((index = supported.indexOf('presenter')) > -1)
|
||||
supported.splice(index, 1);
|
||||
|
||||
device.settings.set_strv('supported-plugins', supported);
|
||||
}
|
||||
|
||||
// Second we need each backend to rebuild its identity packet and
|
||||
// broadcast the amended capabilities to the network
|
||||
for (const backend of service.manager.backends.values())
|
||||
backend.buildIdentity();
|
||||
|
||||
service.manager.identify();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_onNameAppeared(connection, name, name_owner) {
|
||||
try {
|
||||
this._connection = connection;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onNameVanished(connection, name) {
|
||||
try {
|
||||
if (this._session !== null)
|
||||
this._onSessionClosed(this._session);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onSessionClosed(session) {
|
||||
// Disconnect from the session
|
||||
if (this._sessionClosedId > 0) {
|
||||
session.disconnect(this._sessionClosedId);
|
||||
this._sessionClosedId = 0;
|
||||
}
|
||||
|
||||
// Destroy the session
|
||||
session.destroy();
|
||||
this._session = null;
|
||||
}
|
||||
|
||||
_onSessionExpired() {
|
||||
// If the session has been used recently, schedule a new expiry
|
||||
const remainder = Math.floor(this._sessionExpiry - (Date.now() / 1000));
|
||||
|
||||
if (remainder > 0) {
|
||||
this._sessionExpiryId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
remainder,
|
||||
this._onSessionExpired.bind(this)
|
||||
);
|
||||
|
||||
return GLib.SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Otherwise if there's an active session, close it
|
||||
if (this._session !== null)
|
||||
this._session.stop();
|
||||
|
||||
// Reset the GSource Id
|
||||
this._sessionExpiryId = 0;
|
||||
|
||||
return GLib.SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
_createRemoteDesktopSession() {
|
||||
if (this.connection === null)
|
||||
return Promise.reject(new Error('No DBus connection'));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connection.call(
|
||||
'org.gnome.Mutter.RemoteDesktop',
|
||||
'/org/gnome/Mutter/RemoteDesktop',
|
||||
'org.gnome.Mutter.RemoteDesktop',
|
||||
'CreateSession',
|
||||
null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
res = connection.call_finish(res);
|
||||
resolve(res.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_createScreenCastSession(sessionId) {
|
||||
if (this.connection === null)
|
||||
return Promise.reject(new Error('No DBus connection'));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = new GLib.Variant('(a{sv})', [{
|
||||
'disable-animations': GLib.Variant.new_boolean(false),
|
||||
'remote-desktop-session-id': GLib.Variant.new_string(sessionId),
|
||||
}]);
|
||||
|
||||
this.connection.call(
|
||||
'org.gnome.Mutter.ScreenCast',
|
||||
'/org/gnome/Mutter/ScreenCast',
|
||||
'org.gnome.Mutter.ScreenCast',
|
||||
'CreateSession',
|
||||
options,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
res = connection.call_finish(res);
|
||||
resolve(res.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async _ensureAdapter() {
|
||||
try {
|
||||
// Update the timestamp of the last event
|
||||
this._sessionExpiry = Math.floor((Date.now() / 1000) + SESSION_TIMEOUT);
|
||||
|
||||
// Session is active
|
||||
if (this._session !== null)
|
||||
return;
|
||||
|
||||
// Mutter's RemoteDesktop is not available, fall back to Atspi
|
||||
if (this.connection === null) {
|
||||
debug('Falling back to Atspi');
|
||||
|
||||
// If we got here in Wayland, we need to re-adjust and bail
|
||||
if (this._checkWayland())
|
||||
return;
|
||||
|
||||
const fallback = imports.service.components.atspi;
|
||||
this._session = new fallback.Controller();
|
||||
|
||||
// Mutter is available and there isn't another session starting
|
||||
} else if (this._sessionStarting === false) {
|
||||
this._sessionStarting = true;
|
||||
|
||||
debug('Creating Mutter RemoteDesktop session');
|
||||
|
||||
// This takes three steps: creating the remote desktop session,
|
||||
// starting the session, and creating a screencast session for
|
||||
// the remote desktop session.
|
||||
const objectPath = await this._createRemoteDesktopSession();
|
||||
|
||||
this._session = new RemoteSession(objectPath);
|
||||
await this._session.start();
|
||||
|
||||
await this._createScreenCastSession(this._session.session_id);
|
||||
|
||||
// Watch for the session ending
|
||||
this._sessionClosedId = this._session.connect(
|
||||
'closed',
|
||||
this._onSessionClosed.bind(this)
|
||||
);
|
||||
|
||||
if (this._sessionExpiryId === 0) {
|
||||
this._sessionExpiryId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
SESSION_TIMEOUT,
|
||||
this._onSessionExpired.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
this._sessionStarting = false;
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
|
||||
if (this._session !== null) {
|
||||
this._session.destroy();
|
||||
this._session = null;
|
||||
}
|
||||
|
||||
this._sessionStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Pointer Events
|
||||
*/
|
||||
movePointer(dx, dy) {
|
||||
try {
|
||||
if (dx === 0 && dy === 0)
|
||||
return;
|
||||
|
||||
this._ensureAdapter();
|
||||
this._session.movePointer(dx, dy);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
pressPointer(button) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.pressPointer(button);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
releasePointer(button) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.releasePointer(button);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
clickPointer(button) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.clickPointer(button);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
doubleclickPointer(button) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.doubleclickPointer(button);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
scrollPointer(dx, dy) {
|
||||
if (dx === 0 && dy === 0)
|
||||
return;
|
||||
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.scrollPointer(dx, dy);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Keyboard Events
|
||||
*/
|
||||
pressKeysym(keysym) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.pressKeysym(keysym);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
releaseKeysym(keysym) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.releaseKeysym(keysym);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
pressreleaseKeysym(keysym) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.pressreleaseKeysym(keysym);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* High-level keyboard input
|
||||
*/
|
||||
pressKey(input, modifiers) {
|
||||
try {
|
||||
this._ensureAdapter();
|
||||
this._session.pressKey(input, modifiers);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._session !== null) {
|
||||
// Disconnect from the session
|
||||
if (this._sessionClosedId > 0) {
|
||||
this._session.disconnect(this._sessionClosedId);
|
||||
this._sessionClosedId = 0;
|
||||
}
|
||||
|
||||
this._session.destroy();
|
||||
this._session = null;
|
||||
}
|
||||
|
||||
if (this._nameWatcherId > 0) {
|
||||
Gio.bus_unwatch_name(this._nameWatcherId);
|
||||
this._nameWatcherId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Controller;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GjsPrivate = imports.gi.GjsPrivate;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const DBus = imports.service.utils.dbus;
|
||||
|
||||
|
||||
const _nodeInfo = Gio.DBusNodeInfo.new_for_xml(`
|
||||
<node>
|
||||
<interface name="org.freedesktop.Notifications">
|
||||
<method name="Notify">
|
||||
<arg name="appName" type="s" direction="in"/>
|
||||
<arg name="replacesId" type="u" direction="in"/>
|
||||
<arg name="iconName" type="s" direction="in"/>
|
||||
<arg name="summary" type="s" direction="in"/>
|
||||
<arg name="body" type="s" direction="in"/>
|
||||
<arg name="actions" type="as" direction="in"/>
|
||||
<arg name="hints" type="a{sv}" direction="in"/>
|
||||
<arg name="timeout" type="i" direction="in"/>
|
||||
</method>
|
||||
</interface>
|
||||
<interface name="org.gtk.Notifications">
|
||||
<method name="AddNotification">
|
||||
<arg type="s" direction="in"/>
|
||||
<arg type="s" direction="in"/>
|
||||
<arg type="a{sv}" direction="in"/>
|
||||
</method>
|
||||
<method name="RemoveNotification">
|
||||
<arg type="s" direction="in"/>
|
||||
<arg type="s" direction="in"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
`);
|
||||
|
||||
|
||||
const FDO_IFACE = _nodeInfo.lookup_interface('org.freedesktop.Notifications');
|
||||
const FDO_MATCH = "interface='org.freedesktop.Notifications',member='Notify',type='method_call'";
|
||||
|
||||
const GTK_IFACE = _nodeInfo.lookup_interface('org.gtk.Notifications');
|
||||
const GTK_MATCH = "interface='org.gtk.Notifications',member='AddNotification',type='method_call'";
|
||||
|
||||
|
||||
/**
|
||||
* A class for snooping Freedesktop (libnotify) and Gtk (GNotification)
|
||||
* notifications and forwarding them to supporting devices.
|
||||
*/
|
||||
const Listener = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNotificationListener',
|
||||
Signals: {
|
||||
'notification-added': {
|
||||
flags: GObject.SignalFlags.RUN_LAST,
|
||||
param_types: [GLib.Variant.$gtype],
|
||||
},
|
||||
},
|
||||
}, class Listener extends GObject.Object {
|
||||
|
||||
_init() {
|
||||
super._init();
|
||||
|
||||
// Respect desktop notification settings
|
||||
this._settings = new Gio.Settings({
|
||||
schema_id: 'org.gnome.desktop.notifications',
|
||||
});
|
||||
|
||||
// Watch for new application policies
|
||||
this._settingsId = this._settings.connect(
|
||||
'changed::application-children',
|
||||
this._onSettingsChanged.bind(this)
|
||||
);
|
||||
|
||||
// Cache for appName->desktop-id lookups
|
||||
this._names = {};
|
||||
|
||||
// Asynchronous setup
|
||||
this._init_async();
|
||||
}
|
||||
|
||||
get applications() {
|
||||
if (this._applications === undefined)
|
||||
this._onSettingsChanged();
|
||||
|
||||
return this._applications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update application notification settings
|
||||
*/
|
||||
_onSettingsChanged() {
|
||||
this._applications = {};
|
||||
|
||||
for (const app of this._settings.get_strv('application-children')) {
|
||||
const appSettings = new Gio.Settings({
|
||||
schema_id: 'org.gnome.desktop.notifications.application',
|
||||
path: `/org/gnome/desktop/notifications/application/${app}/`,
|
||||
});
|
||||
|
||||
const appInfo = Gio.DesktopAppInfo.new(
|
||||
appSettings.get_string('application-id')
|
||||
);
|
||||
|
||||
if (appInfo !== null)
|
||||
this._applications[appInfo.get_name()] = appSettings;
|
||||
}
|
||||
}
|
||||
|
||||
_listNames() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._session.call(
|
||||
'org.freedesktop.DBus',
|
||||
'/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus',
|
||||
'ListNames',
|
||||
null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
res = connection.call_finish(res);
|
||||
resolve(res.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_getNameOwner(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._session.call(
|
||||
'org.freedesktop.DBus',
|
||||
'/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus',
|
||||
'GetNameOwner',
|
||||
new GLib.Variant('(s)', [name]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
res = connection.call_finish(res);
|
||||
resolve(res.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try and find a well-known name for @sender on the session bus
|
||||
*
|
||||
* @param {string} sender - A DBus unique name (eg. :1.2282)
|
||||
* @param {string} appName - @appName passed to Notify() (Optional)
|
||||
* @return {string} A well-known name or %null
|
||||
*/
|
||||
async _getAppId(sender, appName) {
|
||||
try {
|
||||
// Get a list of well-known names, ignoring @sender
|
||||
const names = await this._listNames();
|
||||
names.splice(names.indexOf(sender), 1);
|
||||
|
||||
// Make a short list for substring matches (fractal/org.gnome.Fractal)
|
||||
const appLower = appName.toLowerCase();
|
||||
|
||||
const shortList = names.filter(name => {
|
||||
return name.toLowerCase().includes(appLower);
|
||||
});
|
||||
|
||||
// Run the short list first
|
||||
for (const name of shortList) {
|
||||
const nameOwner = await this._getNameOwner(name);
|
||||
|
||||
if (nameOwner === sender)
|
||||
return name;
|
||||
|
||||
names.splice(names.indexOf(name), 1);
|
||||
}
|
||||
|
||||
// Run the full list
|
||||
for (const name of names) {
|
||||
const nameOwner = await this._getNameOwner(name);
|
||||
|
||||
if (nameOwner === sender)
|
||||
return name;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try and find the application name for @sender
|
||||
*
|
||||
* @param {string} sender - A DBus unique name
|
||||
* @param {string} [appName] - `appName` supplied by Notify()
|
||||
* @return {string} A well-known name or %null
|
||||
*/
|
||||
async _getAppName(sender, appName = null) {
|
||||
// Check the cache first
|
||||
if (appName && this._names.hasOwnProperty(appName))
|
||||
return this._names[appName];
|
||||
|
||||
try {
|
||||
const appId = await this._getAppId(sender, appName);
|
||||
const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`);
|
||||
this._names[appName] = appInfo.get_name();
|
||||
appName = appInfo.get_name();
|
||||
} catch (e) {
|
||||
// Silence errors
|
||||
}
|
||||
|
||||
return appName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for AddNotification()/Notify()
|
||||
*
|
||||
* @param {DBus.Interface} iface - The DBus interface
|
||||
* @param {string} name - The DBus method name
|
||||
* @param {GLib.Variant} parameters - The method parameters
|
||||
* @param {Gio.DBusMethodInvocation} invocation - The method invocation info
|
||||
*/
|
||||
async _onHandleMethodCall(iface, name, parameters, invocation) {
|
||||
try {
|
||||
// Check if notifications are disabled in desktop settings
|
||||
if (!this._settings.get_boolean('show-banners'))
|
||||
return;
|
||||
|
||||
parameters = parameters.full_unpack();
|
||||
|
||||
// GNotification
|
||||
if (name === 'AddNotification') {
|
||||
this.AddNotification(...parameters);
|
||||
|
||||
// libnotify
|
||||
} else if (name === 'Notify') {
|
||||
const message = invocation.get_message();
|
||||
|
||||
if (this._fdoNameOwner === undefined) {
|
||||
this._fdoNameOwner = await this._getNameOwner(
|
||||
'org.freedesktop.Notifications');
|
||||
}
|
||||
|
||||
if (this._fdoNameOwner !== message.get_destination())
|
||||
return;
|
||||
|
||||
// Try to brute-force an application name using DBus
|
||||
if (!this.applications.hasOwnProperty(parameters[0])) {
|
||||
const sender = message.get_sender();
|
||||
parameters[0] = await this._getAppName(sender, parameters[0]);
|
||||
}
|
||||
|
||||
this.Notify(...parameters);
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export interfaces for proxying notifications and become a monitor
|
||||
*
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_monitorConnection() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// libnotify Interface
|
||||
this._fdoNotifications = new GjsPrivate.DBusImplementation({
|
||||
g_interface_info: FDO_IFACE,
|
||||
});
|
||||
this._fdoMethodCallId = this._fdoNotifications.connect(
|
||||
'handle-method-call',
|
||||
this._onHandleMethodCall.bind(this)
|
||||
);
|
||||
this._fdoNotifications.export(
|
||||
this._monitor,
|
||||
'/org/freedesktop/Notifications'
|
||||
);
|
||||
|
||||
this._fdoNameOwnerChangedId = this._session.signal_subscribe(
|
||||
'org.freedesktop.DBus',
|
||||
'org.freedesktop.DBus',
|
||||
'NameOwnerChanged',
|
||||
'/org/freedesktop/DBus',
|
||||
'org.freedesktop.Notifications',
|
||||
Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE,
|
||||
this._onFdoNameOwnerChanged.bind(this)
|
||||
);
|
||||
|
||||
// GNotification Interface
|
||||
this._gtkNotifications = new GjsPrivate.DBusImplementation({
|
||||
g_interface_info: GTK_IFACE,
|
||||
});
|
||||
this._gtkMethodCallId = this._gtkNotifications.connect(
|
||||
'handle-method-call',
|
||||
this._onHandleMethodCall.bind(this)
|
||||
);
|
||||
this._gtkNotifications.export(
|
||||
this._monitor,
|
||||
'/org/gtk/Notifications'
|
||||
);
|
||||
|
||||
// Become a monitor for Fdo & Gtk notifications
|
||||
this._monitor.call(
|
||||
'org.freedesktop.DBus',
|
||||
'/org/freedesktop/DBus',
|
||||
'org.freedesktop.DBus.Monitoring',
|
||||
'BecomeMonitor',
|
||||
new GLib.Variant('(asu)', [[FDO_MATCH, GTK_MATCH], 0]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
resolve(connection.call_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async _init_async() {
|
||||
try {
|
||||
this._session = await DBus.getConnection();
|
||||
this._monitor = await DBus.newConnection();
|
||||
await this._monitorConnection();
|
||||
} catch (e) {
|
||||
const service = Gio.Application.get_default();
|
||||
|
||||
if (service !== null)
|
||||
service.notify_error(e);
|
||||
else
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onFdoNameOwnerChanged(connection, sender, object, iface, signal, parameters) {
|
||||
this._fdoNameOwner = parameters.deepUnpack()[2];
|
||||
}
|
||||
|
||||
_sendNotification(notif) {
|
||||
// Check if this application is disabled in desktop settings
|
||||
const appSettings = this.applications[notif.appName];
|
||||
|
||||
if (appSettings && !appSettings.get_boolean('enable'))
|
||||
return;
|
||||
|
||||
// Send the notification to each supporting device
|
||||
// TODO: avoid the overhead of the GAction framework with a signal?
|
||||
const variant = GLib.Variant.full_pack(notif);
|
||||
this.emit('notification-added', variant);
|
||||
}
|
||||
|
||||
Notify(appName, replacesId, iconName, summary, body, actions, hints, timeout) {
|
||||
// Ignore notifications without an appName
|
||||
if (!appName)
|
||||
return;
|
||||
|
||||
this._sendNotification({
|
||||
appName: appName,
|
||||
id: `fdo|null|${replacesId}`,
|
||||
title: summary,
|
||||
text: body,
|
||||
ticker: `${summary}: ${body}`,
|
||||
isClearable: (replacesId !== 0),
|
||||
icon: iconName,
|
||||
});
|
||||
}
|
||||
|
||||
AddNotification(application, id, notification) {
|
||||
// Ignore our own notifications or we'll cause a notification loop
|
||||
if (application === 'org.gnome.Shell.Extensions.GSConnect')
|
||||
return;
|
||||
|
||||
const appInfo = Gio.DesktopAppInfo.new(`${application}.desktop`);
|
||||
|
||||
// Try to get an icon for the notification
|
||||
if (!notification.hasOwnProperty('icon'))
|
||||
notification.icon = appInfo.get_icon() || undefined;
|
||||
|
||||
this._sendNotification({
|
||||
appName: appInfo.get_name(),
|
||||
id: `gtk|${application}|${id}`,
|
||||
title: notification.title,
|
||||
text: notification.body,
|
||||
ticker: `${notification.title}: ${notification.body}`,
|
||||
isClearable: true,
|
||||
icon: notification.icon,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
if (this._fdoNotifications) {
|
||||
this._fdoNotifications.disconnect(this._fdoMethodCallId);
|
||||
this._fdoNotifications.unexport();
|
||||
this._session.signal_unsubscribe(this._fdoNameOwnerChangedId);
|
||||
}
|
||||
|
||||
if (this._gtkNotifications) {
|
||||
this._gtkNotifications.disconnect(this._gtkMethodCallId);
|
||||
this._gtkNotifications.unexport();
|
||||
}
|
||||
|
||||
if (this._settings) {
|
||||
this._settings.disconnect(this._settingsId);
|
||||
this._settings.run_dispose();
|
||||
}
|
||||
|
||||
// TODO: Gio.IOErrorEnum: The connection is closed
|
||||
// this._monitor.close_sync(null);
|
||||
|
||||
GObject.signal_handlers_destroy(this);
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Listener;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
'use strict';
|
||||
|
||||
const Tweener = imports.tweener.tweener;
|
||||
|
||||
const GIRepository = imports.gi.GIRepository;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
|
||||
// Add gnome-shell's typelib dir to the search path
|
||||
const typelibDir = GLib.build_filenamev([Config.GNOME_SHELL_LIBDIR, 'gnome-shell']);
|
||||
GIRepository.Repository.prepend_search_path(typelibDir);
|
||||
GIRepository.Repository.prepend_library_path(typelibDir);
|
||||
|
||||
const Gvc = imports.gi.Gvc;
|
||||
|
||||
|
||||
/**
|
||||
* Extend Gvc.MixerStream with a property for returning a user-visible name
|
||||
*/
|
||||
Object.defineProperty(Gvc.MixerStream.prototype, 'display_name', {
|
||||
get: function () {
|
||||
try {
|
||||
if (!this.get_ports().length)
|
||||
return this.description;
|
||||
|
||||
return `${this.get_port().human_port} (${this.description})`;
|
||||
} catch (e) {
|
||||
return this.description;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A convenience wrapper for Gvc.MixerStream
|
||||
*/
|
||||
class Stream {
|
||||
constructor(mixer, stream) {
|
||||
this._mixer = mixer;
|
||||
this._stream = stream;
|
||||
|
||||
this._max = mixer.get_vol_max_norm();
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this._stream.is_muted;
|
||||
}
|
||||
|
||||
set muted(bool) {
|
||||
this._stream.change_is_muted(bool);
|
||||
}
|
||||
|
||||
// Volume is a double in the range 0-1
|
||||
get volume() {
|
||||
return Math.floor(100 * this._stream.volume / this._max) / 100;
|
||||
}
|
||||
|
||||
set volume(num) {
|
||||
this._stream.volume = Math.floor(num * this._max);
|
||||
this._stream.push_volume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gradually raise or lower the stream volume to @value
|
||||
*
|
||||
* @param {number} value - A number in the range 0-1
|
||||
* @param {number} [duration] - Duration to fade in seconds
|
||||
*/
|
||||
fade(value, duration = 1) {
|
||||
Tweener.removeTweens(this);
|
||||
|
||||
if (this._stream.volume > value) {
|
||||
this._mixer.fading = true;
|
||||
|
||||
Tweener.addTween(this, {
|
||||
volume: value,
|
||||
time: duration,
|
||||
transition: 'easeOutCubic',
|
||||
onComplete: () => {
|
||||
this._mixer.fading = false;
|
||||
},
|
||||
});
|
||||
} else if (this._stream.volume < value) {
|
||||
this._mixer.fading = true;
|
||||
|
||||
Tweener.addTween(this, {
|
||||
volume: value,
|
||||
time: duration,
|
||||
transition: 'easeInCubic',
|
||||
onComplete: () => {
|
||||
this._mixer.fading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A subclass of Gvc.MixerControl with convenience functions for controlling the
|
||||
* default input/output volumes.
|
||||
*
|
||||
* The Mixer class uses GNOME Shell's Gvc library to control the system volume
|
||||
* and offers a few convenience functions.
|
||||
*/
|
||||
const Mixer = GObject.registerClass({
|
||||
GTypeName: 'GSConnectAudioMixer',
|
||||
}, class Mixer extends Gvc.MixerControl {
|
||||
_init(params) {
|
||||
super._init({name: 'GSConnect'});
|
||||
|
||||
this._previousVolume = undefined;
|
||||
this._volumeMuted = false;
|
||||
this._microphoneMuted = false;
|
||||
|
||||
this.open();
|
||||
}
|
||||
|
||||
get fading() {
|
||||
if (this._fading === undefined)
|
||||
this._fading = false;
|
||||
|
||||
return this._fading;
|
||||
}
|
||||
|
||||
set fading(bool) {
|
||||
if (this.fading === bool)
|
||||
return;
|
||||
|
||||
this._fading = bool;
|
||||
|
||||
if (this.fading)
|
||||
this.emit('stream-changed', this._output._stream.id);
|
||||
}
|
||||
|
||||
get input() {
|
||||
if (this._input === undefined)
|
||||
this.vfunc_default_source_changed();
|
||||
|
||||
return this._input;
|
||||
}
|
||||
|
||||
get output() {
|
||||
if (this._output === undefined)
|
||||
this.vfunc_default_sink_changed();
|
||||
|
||||
return this._output;
|
||||
}
|
||||
|
||||
vfunc_default_sink_changed(id) {
|
||||
try {
|
||||
const sink = this.get_default_sink();
|
||||
this._output = (sink) ? new Stream(this, sink) : null;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
vfunc_default_source_changed(id) {
|
||||
try {
|
||||
const source = this.get_default_source();
|
||||
this._input = (source) ? new Stream(this, source) : null;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
vfunc_state_changed(new_state) {
|
||||
try {
|
||||
if (new_state === Gvc.MixerControlState.READY) {
|
||||
this.vfunc_default_sink_changed(null);
|
||||
this.vfunc_default_source_changed(null);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the current output volume then lower it to %15
|
||||
*
|
||||
* @param {number} duration - Duration in seconds to fade
|
||||
*/
|
||||
lowerVolume(duration = 1) {
|
||||
try {
|
||||
if (this.output.volume > 0.15) {
|
||||
this._previousVolume = Number(this.output.volume);
|
||||
this.output.fade(0.15, duration);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the output volume (speakers)
|
||||
*/
|
||||
muteVolume() {
|
||||
try {
|
||||
if (this.output.muted)
|
||||
return;
|
||||
|
||||
this.output.muted = true;
|
||||
this._volumeMuted = true;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute the input volume (microphone)
|
||||
*/
|
||||
muteMicrophone() {
|
||||
try {
|
||||
if (this.input.muted)
|
||||
return;
|
||||
|
||||
this.input.muted = true;
|
||||
this._microphoneMuted = true;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all mixer levels to their previous state
|
||||
*/
|
||||
restore() {
|
||||
try {
|
||||
// If we muted the microphone, unmute it before restoring the volume
|
||||
if (this._microphoneMuted) {
|
||||
this.input.muted = false;
|
||||
this._microphoneMuted = false;
|
||||
}
|
||||
|
||||
// If we muted the volume, unmute it before restoring the volume
|
||||
if (this._volumeMuted) {
|
||||
this.output.muted = false;
|
||||
this._volumeMuted = false;
|
||||
}
|
||||
|
||||
// If a previous volume is defined, raise it back up to that level
|
||||
if (this._previousVolume !== undefined) {
|
||||
this.output.fade(this._previousVolume);
|
||||
this._previousVolume = undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Mixer;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
|
||||
|
||||
const Session = class {
|
||||
constructor() {
|
||||
this._connection = Gio.DBus.system;
|
||||
this._session = null;
|
||||
|
||||
this._initAsync();
|
||||
}
|
||||
|
||||
async _initAsync() {
|
||||
try {
|
||||
const userName = GLib.get_user_name();
|
||||
const sessions = await this._listSessions();
|
||||
let sessionPath = '/org/freedesktop/login1/session/auto';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for (const [num, uid, name, seat, objectPath] of sessions) {
|
||||
if (name === userName) {
|
||||
sessionPath = objectPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this._session = await this._getSession(sessionPath);
|
||||
} catch (e) {
|
||||
this._session = null;
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
get idle() {
|
||||
if (this._session === null)
|
||||
return false;
|
||||
|
||||
return this._session.get_cached_property('IdleHint').unpack();
|
||||
}
|
||||
|
||||
get locked() {
|
||||
if (this._session === null)
|
||||
return false;
|
||||
|
||||
return this._session.get_cached_property('LockedHint').unpack();
|
||||
}
|
||||
|
||||
get active() {
|
||||
// Active if not idle and not locked
|
||||
return !(this.idle || this.locked);
|
||||
}
|
||||
|
||||
_listSessions() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._connection.call(
|
||||
'org.freedesktop.login1',
|
||||
'/org/freedesktop/login1',
|
||||
'org.freedesktop.login1.Manager',
|
||||
'ListSessions',
|
||||
null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
res = connection.call_finish(res);
|
||||
resolve(res.deepUnpack()[0]);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async _getSession(objectPath) {
|
||||
const session = new Gio.DBusProxy({
|
||||
g_connection: this._connection,
|
||||
g_name: 'org.freedesktop.login1',
|
||||
g_object_path: objectPath,
|
||||
g_interface_name: 'org.freedesktop.login1.Session',
|
||||
});
|
||||
|
||||
// Initialize the proxy
|
||||
await new Promise((resolve, reject) => {
|
||||
session.init_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
resolve(proxy.init_finish(res));
|
||||
} catch (e) {
|
||||
Gio.DBusError.strip_remote_error(e);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._session = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Session;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
|
||||
|
||||
/*
|
||||
* Used to ensure 'audible-bell' is enabled for fallback
|
||||
*/
|
||||
const WM_SETTINGS = new Gio.Settings({
|
||||
schema_id: 'org.gnome.desktop.wm.preferences',
|
||||
path: '/org/gnome/desktop/wm/preferences/',
|
||||
});
|
||||
|
||||
|
||||
var Player = class Player {
|
||||
|
||||
constructor() {
|
||||
this._playing = new Set();
|
||||
}
|
||||
|
||||
get backend() {
|
||||
if (this._backend === undefined) {
|
||||
// Prefer GSound
|
||||
try {
|
||||
this._gsound = new imports.gi.GSound.Context();
|
||||
this._gsound.init(null);
|
||||
this._backend = 'gsound';
|
||||
|
||||
// Try falling back to libcanberra, otherwise just re-run the test
|
||||
// in case one or the other is installed later
|
||||
} catch (e) {
|
||||
if (GLib.find_program_in_path('canberra-gtk-play') !== null) {
|
||||
this._canberra = new Gio.SubprocessLauncher({
|
||||
flags: Gio.SubprocessFlags.NONE,
|
||||
});
|
||||
this._backend = 'libcanberra';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._backend;
|
||||
}
|
||||
|
||||
_canberraPlaySound(name, cancellable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = this._canberra.spawnv(['canberra-gtk-play', '-i', name]);
|
||||
|
||||
proc.wait_check_async(cancellable, (proc, res) => {
|
||||
try {
|
||||
resolve(proc.wait_check_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async _canberraLoopSound(name, cancellable) {
|
||||
while (!cancellable.is_cancelled())
|
||||
await this._canberraPlaySound(name, cancellable);
|
||||
}
|
||||
|
||||
_gsoundPlaySound(name, cancellable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._gsound.play_full(
|
||||
{'event.id': name},
|
||||
cancellable,
|
||||
(source, res) => {
|
||||
try {
|
||||
resolve(source.play_full_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async _gsoundLoopSound(name, cancellable) {
|
||||
while (!cancellable.is_cancelled())
|
||||
await this._gsoundPlaySound(name, cancellable);
|
||||
}
|
||||
|
||||
_gdkPlaySound(name, cancellable) {
|
||||
if (this._display === undefined)
|
||||
this._display = Gdk.Display.get_default();
|
||||
|
||||
let count = 0;
|
||||
|
||||
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 200, () => {
|
||||
try {
|
||||
if (count++ < 4 && !cancellable.is_cancelled()) {
|
||||
this._display.beep();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
return GLib.SOURCE_REMOVE;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
}
|
||||
});
|
||||
|
||||
return !cancellable.is_cancelled();
|
||||
}
|
||||
|
||||
_gdkLoopSound(name, cancellable) {
|
||||
this._gdkPlaySound(name, cancellable);
|
||||
GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
1500,
|
||||
this._gdkPlaySound.bind(this, name, cancellable)
|
||||
);
|
||||
}
|
||||
|
||||
async playSound(name, cancellable) {
|
||||
try {
|
||||
if (!(cancellable instanceof Gio.Cancellable))
|
||||
cancellable = new Gio.Cancellable();
|
||||
|
||||
this._playing.add(cancellable);
|
||||
|
||||
switch (this.backend) {
|
||||
case 'gsound':
|
||||
await this._gsoundPlaySound(name, cancellable);
|
||||
break;
|
||||
|
||||
case 'canberra':
|
||||
await this._canberraPlaySound(name, cancellable);
|
||||
break;
|
||||
|
||||
default:
|
||||
await this._gdkPlaySound(name, cancellable);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
||||
logError(e);
|
||||
} finally {
|
||||
this._playing.delete(cancellable);
|
||||
}
|
||||
}
|
||||
|
||||
async loopSound(name, cancellable) {
|
||||
try {
|
||||
if (!(cancellable instanceof Gio.Cancellable))
|
||||
cancellable = new Gio.Cancellable();
|
||||
|
||||
this._playing.add(cancellable);
|
||||
|
||||
switch (this.backend) {
|
||||
case 'gsound':
|
||||
await this._gsoundLoopSound(name, cancellable);
|
||||
break;
|
||||
|
||||
case 'canberra':
|
||||
await this._canberraLoopSound(name, cancellable);
|
||||
break;
|
||||
|
||||
default:
|
||||
await this._gdkLoopSound(name, cancellable);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
||||
logError(e);
|
||||
} finally {
|
||||
this._playing.delete(cancellable);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const cancellable of this._playing)
|
||||
cancellable.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Player;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
|
||||
/**
|
||||
* The warning level of a battery.
|
||||
*
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
const DeviceLevel = {
|
||||
UNKNOWN: 0,
|
||||
NONE: 1,
|
||||
DISCHARGING: 2,
|
||||
LOW: 3,
|
||||
CRITICAL: 4,
|
||||
ACTION: 5,
|
||||
NORMAL: 6,
|
||||
HIGH: 7,
|
||||
FULL: 8,
|
||||
LAST: 9,
|
||||
};
|
||||
|
||||
/**
|
||||
* The device state.
|
||||
*
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
const DeviceState = {
|
||||
UNKNOWN: 0,
|
||||
CHARGING: 1,
|
||||
DISCHARGING: 2,
|
||||
EMPTY: 3,
|
||||
FULLY_CHARGED: 4,
|
||||
PENDING_CHARGE: 5,
|
||||
PENDING_DISCHARGE: 6,
|
||||
LAST: 7,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A class representing the system battery.
|
||||
*/
|
||||
var Battery = GObject.registerClass({
|
||||
GTypeName: 'GSConnectSystemBattery',
|
||||
Signals: {
|
||||
'changed': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
},
|
||||
},
|
||||
Properties: {
|
||||
'charging': GObject.ParamSpec.boolean(
|
||||
'charging',
|
||||
'Charging',
|
||||
'The current charging state.',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'level': GObject.ParamSpec.int(
|
||||
'level',
|
||||
'Level',
|
||||
'The current power level.',
|
||||
GObject.ParamFlags.READABLE,
|
||||
-1, 100,
|
||||
-1
|
||||
),
|
||||
'threshold': GObject.ParamSpec.uint(
|
||||
'threshold',
|
||||
'Threshold',
|
||||
'The current threshold state.',
|
||||
GObject.ParamFlags.READABLE,
|
||||
0, 1,
|
||||
0
|
||||
),
|
||||
},
|
||||
}, class Battery extends GObject.Object {
|
||||
|
||||
_init() {
|
||||
super._init();
|
||||
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
this._proxy = null;
|
||||
this._propertiesChangedId = 0;
|
||||
|
||||
this._loadUPower();
|
||||
}
|
||||
|
||||
async _loadUPower() {
|
||||
try {
|
||||
this._proxy = new Gio.DBusProxy({
|
||||
g_bus_type: Gio.BusType.SYSTEM,
|
||||
g_name: 'org.freedesktop.UPower',
|
||||
g_object_path: '/org/freedesktop/UPower/devices/DisplayDevice',
|
||||
g_interface_name: 'org.freedesktop.UPower.Device',
|
||||
g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this._proxy.init_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this._cancellable,
|
||||
(proxy, res) => {
|
||||
try {
|
||||
resolve(proxy.init_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this._propertiesChangedId = this._proxy.connect(
|
||||
'g-properties-changed',
|
||||
this._onPropertiesChanged.bind(this)
|
||||
);
|
||||
|
||||
this._initProperties(this._proxy);
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
|
||||
const service = Gio.Application.get_default();
|
||||
|
||||
if (service !== null)
|
||||
service.notify_error(e);
|
||||
else
|
||||
logError(e);
|
||||
}
|
||||
|
||||
this._proxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
_initProperties(proxy) {
|
||||
if (proxy.g_name_owner === null)
|
||||
return;
|
||||
|
||||
const percentage = proxy.get_cached_property('Percentage').unpack();
|
||||
const state = proxy.get_cached_property('State').unpack();
|
||||
const level = proxy.get_cached_property('WarningLevel').unpack();
|
||||
|
||||
this._level = Math.floor(percentage);
|
||||
this._charging = (state !== DeviceState.DISCHARGING);
|
||||
this._threshold = (!this.charging && level >= DeviceLevel.LOW);
|
||||
|
||||
this.emit('changed');
|
||||
}
|
||||
|
||||
_onPropertiesChanged(proxy, changed, invalidated) {
|
||||
let emitChanged = false;
|
||||
const properties = changed.deepUnpack();
|
||||
|
||||
if (properties.hasOwnProperty('Percentage')) {
|
||||
emitChanged = true;
|
||||
|
||||
const value = proxy.get_cached_property('Percentage').unpack();
|
||||
this._level = Math.floor(value);
|
||||
this.notify('level');
|
||||
}
|
||||
|
||||
if (properties.hasOwnProperty('State')) {
|
||||
emitChanged = true;
|
||||
|
||||
const value = proxy.get_cached_property('State').unpack();
|
||||
this._charging = (value !== DeviceState.DISCHARGING);
|
||||
this.notify('charging');
|
||||
}
|
||||
|
||||
if (properties.hasOwnProperty('WarningLevel')) {
|
||||
emitChanged = true;
|
||||
|
||||
const value = proxy.get_cached_property('WarningLevel').unpack();
|
||||
this._threshold = (!this.charging && value >= DeviceLevel.LOW);
|
||||
this.notify('threshold');
|
||||
}
|
||||
|
||||
if (emitChanged)
|
||||
this.emit('changed');
|
||||
}
|
||||
|
||||
get charging() {
|
||||
if (this._charging === undefined)
|
||||
this._charging = false;
|
||||
|
||||
return this._charging;
|
||||
}
|
||||
|
||||
get is_present() {
|
||||
return (this._proxy && this._proxy.g_name_owner);
|
||||
}
|
||||
|
||||
get level() {
|
||||
if (this._level === undefined)
|
||||
this._level = -1;
|
||||
|
||||
return this._level;
|
||||
}
|
||||
|
||||
get threshold() {
|
||||
if (this._threshold === undefined)
|
||||
this._threshold = 0;
|
||||
|
||||
return this._threshold;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._cancellable.is_cancelled())
|
||||
return;
|
||||
|
||||
this._cancellable.cancel();
|
||||
|
||||
if (this._proxy && this._propertiesChangedId > 0) {
|
||||
this._proxy.disconnect(this._propertiesChangedId);
|
||||
this._propertiesChangedId = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* The service class for this component
|
||||
*/
|
||||
var Component = Battery;
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
|
||||
/**
|
||||
* Get the local device type.
|
||||
*
|
||||
* @return {string} A device type string
|
||||
*/
|
||||
function _getDeviceType() {
|
||||
try {
|
||||
let type = GLib.file_get_contents('/sys/class/dmi/id/chassis_type')[1];
|
||||
|
||||
type = Number(imports.byteArray.toString(type));
|
||||
|
||||
if ([8, 9, 10, 14].includes(type))
|
||||
return 'laptop';
|
||||
|
||||
return 'desktop';
|
||||
} catch (e) {
|
||||
return 'desktop';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The packet class is a simple Object-derived class, offering some conveniences
|
||||
* for working with KDE Connect packets.
|
||||
*/
|
||||
var Packet = class Packet {
|
||||
|
||||
constructor(data = null) {
|
||||
this.id = 0;
|
||||
this.type = undefined;
|
||||
this.body = {};
|
||||
|
||||
if (typeof data === 'string')
|
||||
Object.assign(this, JSON.parse(data));
|
||||
else if (data !== null)
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive](hint) {
|
||||
this.id = Date.now();
|
||||
|
||||
if (hint === 'string')
|
||||
return `${JSON.stringify(this)}\n`;
|
||||
|
||||
if (hint === 'number')
|
||||
return `${JSON.stringify(this)}\n`.length;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() {
|
||||
return `Packet:${this.type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize and return a new Packet from an Object or string.
|
||||
*
|
||||
* @param {Object|string} data - A string or dictionary to deserialize
|
||||
* @return {Core.Packet} A new packet object
|
||||
*/
|
||||
static deserialize(data) {
|
||||
return new Packet(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the packet as a single line with a terminating new-line (`\n`)
|
||||
* character, ready to be written to a channel.
|
||||
*
|
||||
* @return {string} A serialized packet
|
||||
*/
|
||||
serialize() {
|
||||
this.id = Date.now();
|
||||
return `${JSON.stringify(this)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the packet from a dictionary or string of JSON
|
||||
*
|
||||
* @param {Object|string} source - Source data
|
||||
*/
|
||||
update(source) {
|
||||
try {
|
||||
if (typeof data === 'string')
|
||||
Object.assign(this, JSON.parse(source));
|
||||
else
|
||||
Object.assign(this, source);
|
||||
} catch (e) {
|
||||
throw Error(`Malformed data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the packet has a payload.
|
||||
*
|
||||
* @return {boolean} %true if @packet has a payload
|
||||
*/
|
||||
hasPayload() {
|
||||
if (!this.hasOwnProperty('payloadSize'))
|
||||
return false;
|
||||
|
||||
if (!this.hasOwnProperty('payloadTransferInfo'))
|
||||
return false;
|
||||
|
||||
return (Object.keys(this.payloadTransferInfo).length > 0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Channel objects handle KDE Connect packet exchange and data transfers for
|
||||
* devices. The implementation is responsible for all negotiation of the
|
||||
* underlying protocol.
|
||||
*/
|
||||
var Channel = GObject.registerClass({
|
||||
GTypeName: 'GSConnectChannel',
|
||||
Properties: {
|
||||
'closed': GObject.ParamSpec.boolean(
|
||||
'closed',
|
||||
'Closed',
|
||||
'Whether the channel has been closed',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
},
|
||||
}, class Channel extends GObject.Object {
|
||||
|
||||
get address() {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
get backend() {
|
||||
if (this._backend === undefined)
|
||||
this._backend = null;
|
||||
|
||||
return this._backend;
|
||||
}
|
||||
|
||||
set backend(backend) {
|
||||
this._backend = backend;
|
||||
}
|
||||
|
||||
get cancellable() {
|
||||
if (this._cancellable === undefined)
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
|
||||
return this._cancellable;
|
||||
}
|
||||
|
||||
get closed() {
|
||||
if (this._closed === undefined)
|
||||
this._closed = false;
|
||||
|
||||
return this._closed;
|
||||
}
|
||||
|
||||
get input_stream() {
|
||||
if (this._input_stream === undefined) {
|
||||
if (this._connection instanceof Gio.IOStream)
|
||||
return this._connection.get_input_stream();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._input_stream;
|
||||
}
|
||||
|
||||
set input_stream(stream) {
|
||||
this._input_stream = stream;
|
||||
}
|
||||
|
||||
get output_stream() {
|
||||
if (this._output_stream === undefined) {
|
||||
if (this._connection instanceof Gio.IOStream)
|
||||
return this._connection.get_output_stream();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._output_stream;
|
||||
}
|
||||
|
||||
set output_stream(stream) {
|
||||
this._output_stream = stream;
|
||||
}
|
||||
|
||||
get uuid() {
|
||||
if (this._uuid === undefined)
|
||||
this._uuid = GLib.uuid_string_random();
|
||||
|
||||
return this._uuid;
|
||||
}
|
||||
|
||||
set uuid(uuid) {
|
||||
this._uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the channel.
|
||||
*/
|
||||
close() {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a packet.
|
||||
*
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable
|
||||
* @return {Promise<Core.Packet>} The packet
|
||||
*/
|
||||
readPacket(cancellable = null) {
|
||||
if (cancellable === null)
|
||||
cancellable = this.cancellable;
|
||||
|
||||
if (!(this.input_stream instanceof Gio.DataInputStream)) {
|
||||
this.input_stream = new Gio.DataInputStream({
|
||||
base_stream: this.input_stream,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.input_stream.read_line_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
cancellable,
|
||||
(stream, res) => {
|
||||
try {
|
||||
const data = stream.read_line_finish_utf8(res)[0];
|
||||
|
||||
if (data === null) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
message: 'End of stream',
|
||||
code: Gio.IOErrorEnum.CONNECTION_CLOSED,
|
||||
});
|
||||
}
|
||||
|
||||
resolve(new Packet(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet.
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet to send
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable
|
||||
* @return {Promise<boolean>} %true if successful
|
||||
*/
|
||||
sendPacket(packet, cancellable = null) {
|
||||
if (cancellable === null)
|
||||
cancellable = this.cancellable;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.output_stream.write_all_async(
|
||||
packet.serialize(),
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
cancellable,
|
||||
(stream, res) => {
|
||||
try {
|
||||
resolve(stream.write_all_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a transfer.
|
||||
*
|
||||
* @param {Core.Packet} packet - A packet with payload info
|
||||
*/
|
||||
rejectTransfer(packet) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a payload from a device. Typically implementations will override
|
||||
* this with an async function.
|
||||
*
|
||||
* @param {Core.Packet} packet - A packet
|
||||
* @param {Gio.OutputStream} target - The target stream
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable for the upload
|
||||
*/
|
||||
download(packet, target, cancellable = null) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Upload a payload to a device. Typically implementations will override
|
||||
* this with an async function.
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet describing the transfer
|
||||
* @param {Gio.InputStream} source - The source stream
|
||||
* @param {number} size - The payload size
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable for the upload
|
||||
*/
|
||||
upload(packet, source, size, cancellable = null) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* ChannelService implementations provide Channel objects, emitting the
|
||||
* ChannelService::channel signal when a new connection has been accepted.
|
||||
*/
|
||||
var ChannelService = GObject.registerClass({
|
||||
GTypeName: 'GSConnectChannelService',
|
||||
Properties: {
|
||||
'active': GObject.ParamSpec.boolean(
|
||||
'active',
|
||||
'Active',
|
||||
'Whether the service is active',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'id': GObject.ParamSpec.string(
|
||||
'id',
|
||||
'ID',
|
||||
'The hostname or other network unique id',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
'name': GObject.ParamSpec.string(
|
||||
'name',
|
||||
'Name',
|
||||
'The name of the backend',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
},
|
||||
Signals: {
|
||||
'channel': {
|
||||
flags: GObject.SignalFlags.RUN_LAST,
|
||||
param_types: [Channel.$gtype],
|
||||
return_type: GObject.TYPE_BOOLEAN,
|
||||
},
|
||||
},
|
||||
}, class ChannelService extends GObject.Object {
|
||||
|
||||
get active() {
|
||||
if (this._active === undefined)
|
||||
this._active = false;
|
||||
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get name() {
|
||||
if (this._name === undefined)
|
||||
this._name = GLib.get_host_name();
|
||||
|
||||
return this._name;
|
||||
}
|
||||
|
||||
set name(name) {
|
||||
if (this.name === name)
|
||||
return;
|
||||
|
||||
this._name = name;
|
||||
this.notify('name');
|
||||
}
|
||||
|
||||
get id() {
|
||||
if (this._id === undefined)
|
||||
this._id = GLib.uuid_string_random();
|
||||
|
||||
return this._id;
|
||||
}
|
||||
|
||||
set id(id) {
|
||||
if (this.id === id)
|
||||
return;
|
||||
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
get identity() {
|
||||
if (this._identity === undefined)
|
||||
this.buildIdentity();
|
||||
|
||||
return this._identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast directly to @address or the whole network if %null
|
||||
*
|
||||
* @param {string} [address] - A string address
|
||||
*/
|
||||
broadcast(address = null) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the identity packet used to identify the local device. An
|
||||
* implementation may override this to make modifications to the default
|
||||
* capabilities if necessary (eg. bluez without SFTP support).
|
||||
*/
|
||||
buildIdentity() {
|
||||
this._identity = new Packet({
|
||||
id: 0,
|
||||
type: 'kdeconnect.identity',
|
||||
body: {
|
||||
deviceId: this.id,
|
||||
deviceName: this.name,
|
||||
deviceType: _getDeviceType(),
|
||||
protocolVersion: 7,
|
||||
incomingCapabilities: [],
|
||||
outgoingCapabilities: [],
|
||||
},
|
||||
});
|
||||
|
||||
for (const name in imports.service.plugins) {
|
||||
// Exclude mousepad/presenter capability in unsupported sessions
|
||||
if (!HAVE_REMOTEINPUT && ['mousepad', 'presenter'].includes(name))
|
||||
continue;
|
||||
|
||||
const meta = imports.service.plugins[name].Metadata;
|
||||
|
||||
for (const type of meta.incomingCapabilities)
|
||||
this._identity.body.incomingCapabilities.push(type);
|
||||
|
||||
for (const type of meta.outgoingCapabilities)
|
||||
this._identity.body.outgoingCapabilities.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit Core.ChannelService::channel
|
||||
*
|
||||
* @param {Core.Channel} channel - The new channel
|
||||
*/
|
||||
channel(channel) {
|
||||
if (!this.emit('channel', channel))
|
||||
channel.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the channel service. Implementations should throw an error if the
|
||||
* service fails to meet any of its requirements for opening or accepting
|
||||
* connections.
|
||||
*/
|
||||
start() {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the channel service.
|
||||
*/
|
||||
stop() {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the channel service.
|
||||
*/
|
||||
destroy() {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A class representing a file transfer.
|
||||
*/
|
||||
var Transfer = GObject.registerClass({
|
||||
GTypeName: 'GSConnectTransfer',
|
||||
Properties: {
|
||||
'channel': GObject.ParamSpec.object(
|
||||
'channel',
|
||||
'Channel',
|
||||
'The channel that owns this transfer',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
Channel.$gtype
|
||||
),
|
||||
'completed': GObject.ParamSpec.boolean(
|
||||
'completed',
|
||||
'Completed',
|
||||
'Whether the transfer has completed',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device that created this transfer',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object.$gtype
|
||||
),
|
||||
},
|
||||
}, class Transfer extends GObject.Object {
|
||||
|
||||
_init(params = {}) {
|
||||
super._init(params);
|
||||
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
this._items = [];
|
||||
}
|
||||
|
||||
get channel() {
|
||||
if (this._channel === undefined)
|
||||
this._channel = null;
|
||||
|
||||
return this._channel;
|
||||
}
|
||||
|
||||
set channel(channel) {
|
||||
if (this.channel === channel)
|
||||
return;
|
||||
|
||||
this._channel = channel;
|
||||
}
|
||||
|
||||
get completed() {
|
||||
if (this._completed === undefined)
|
||||
this._completed = false;
|
||||
|
||||
return this._completed;
|
||||
}
|
||||
|
||||
get device() {
|
||||
if (this._device === undefined)
|
||||
this._device = null;
|
||||
|
||||
return this._device;
|
||||
}
|
||||
|
||||
set device(device) {
|
||||
if (this.device === device)
|
||||
return;
|
||||
|
||||
this._device = device;
|
||||
}
|
||||
|
||||
get uuid() {
|
||||
if (this._uuid === undefined)
|
||||
this._uuid = GLib.uuid_string_random();
|
||||
|
||||
return this._uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure there is a stream for the transfer item.
|
||||
*
|
||||
* @param {Object} item - A transfer item
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable
|
||||
*/
|
||||
async _ensureStream(item, cancellable = null) {
|
||||
// This is an upload from a remote device
|
||||
if (item.packet.hasPayload()) {
|
||||
if (item.target instanceof Gio.OutputStream)
|
||||
return;
|
||||
|
||||
if (item.file instanceof Gio.File) {
|
||||
item.target = await new Promise((resolve, reject) => {
|
||||
item.file.replace_async(
|
||||
null,
|
||||
false,
|
||||
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this._cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.replace_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (item.source instanceof Gio.InputStream)
|
||||
return;
|
||||
|
||||
if (item.file instanceof Gio.File) {
|
||||
const read = new Promise((resolve, reject) => {
|
||||
item.file.read_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.read_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const query = new Promise((resolve, reject) => {
|
||||
item.file.query_info_async(
|
||||
Gio.FILE_ATTRIBUTE_STANDARD_SIZE,
|
||||
Gio.FileQueryInfoFlags.NONE,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.query_info_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const [stream, info] = await Promise.all([read, query]);
|
||||
item.source = stream;
|
||||
item.size = info.get_size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the transfer.
|
||||
*
|
||||
* @param {Core.Packet} packet - A packet
|
||||
* @param {Gio.File} file - A file to transfer
|
||||
*/
|
||||
addFile(packet, file) {
|
||||
const item = {
|
||||
packet: new Packet(packet),
|
||||
file: file,
|
||||
source: null,
|
||||
target: null,
|
||||
};
|
||||
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a filepath to the transfer.
|
||||
*
|
||||
* @param {Core.Packet} packet - A packet
|
||||
* @param {string} path - A filepath to transfer
|
||||
*/
|
||||
addPath(packet, path) {
|
||||
const item = {
|
||||
packet: new Packet(packet),
|
||||
file: Gio.File.new_for_path(path),
|
||||
source: null,
|
||||
target: null,
|
||||
};
|
||||
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a stream to the transfer.
|
||||
*
|
||||
* @param {Core.Packet} packet - A packet
|
||||
* @param {Gio.InputStream|Gio.OutputStream} stream - A stream to transfer
|
||||
* @param {number} [size] - Payload size
|
||||
*/
|
||||
addStream(packet, stream, size = 0) {
|
||||
const item = {
|
||||
packet: new Packet(packet),
|
||||
file: null,
|
||||
source: null,
|
||||
target: null,
|
||||
size: size,
|
||||
};
|
||||
|
||||
if (stream instanceof Gio.InputStream)
|
||||
item.source = stream;
|
||||
else if (stream instanceof Gio.OutputStream)
|
||||
item.target = stream;
|
||||
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transfer operation. Implementations may override this, while
|
||||
* the default uses g_output_stream_splice().
|
||||
*
|
||||
* @param {Gio.Cancellable} [cancellable] - A cancellable
|
||||
*/
|
||||
async start(cancellable = null) {
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
let item;
|
||||
|
||||
// If a cancellable is passed in, chain to its signal
|
||||
if (cancellable instanceof Gio.Cancellable)
|
||||
cancellable.connect(() => this._cancellable.cancel());
|
||||
|
||||
while ((item = this._items.shift())) {
|
||||
// If created for a device, ignore connection changes by
|
||||
// ensuring we have the most recent channel
|
||||
if (this.device !== null)
|
||||
this._channel = this.device.channel;
|
||||
|
||||
// TODO: transfer queueing?
|
||||
if (this.channel === null || this.channel.closed) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.CONNECTION_CLOSED,
|
||||
message: 'Channel is closed',
|
||||
});
|
||||
}
|
||||
|
||||
await this._ensureStream(item, this._cancellable);
|
||||
|
||||
if (item.packet.hasPayload()) {
|
||||
await this.channel.download(item.packet, item.target,
|
||||
this._cancellable);
|
||||
} else {
|
||||
await this.channel.upload(item.packet, item.source,
|
||||
item.size, this._cancellable);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
this._completed = true;
|
||||
this.notify('completed');
|
||||
}
|
||||
|
||||
if (error !== null)
|
||||
throw error;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this._cancellable.is_cancelled() === false)
|
||||
this._cancellable.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env gjs
|
||||
|
||||
'use strict';
|
||||
|
||||
// Allow TLSv1.0 certificates
|
||||
// See https://github.com/andyholmes/gnome-shell-extension-gsconnect/issues/930
|
||||
imports.gi.GLib.setenv('G_TLS_GNUTLS_PRIORITY', 'NORMAL:%COMPAT:+VERS-TLS1.0', true);
|
||||
|
||||
imports.gi.versions.Gdk = '3.0';
|
||||
imports.gi.versions.GdkPixbuf = '2.0';
|
||||
imports.gi.versions.Gio = '2.0';
|
||||
imports.gi.versions.GIRepository = '2.0';
|
||||
imports.gi.versions.GLib = '2.0';
|
||||
imports.gi.versions.GObject = '2.0';
|
||||
imports.gi.versions.Gtk = '3.0';
|
||||
imports.gi.versions.Pango = '1.0';
|
||||
|
||||
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;
|
||||
|
||||
|
||||
// Bootstrap
|
||||
function get_datadir() {
|
||||
const m = /@(.+):\d+/.exec((new Error()).stack.split('\n')[1]);
|
||||
return Gio.File.new_for_path(m[1]).get_parent().get_parent().get_path();
|
||||
}
|
||||
|
||||
imports.searchPath.unshift(get_datadir());
|
||||
imports.config.PACKAGE_DATADIR = imports.searchPath[0];
|
||||
|
||||
|
||||
// Local Imports
|
||||
const Config = imports.config;
|
||||
const Manager = imports.service.manager;
|
||||
const ServiceUI = imports.service.ui.service;
|
||||
|
||||
|
||||
/**
|
||||
* Class representing the GSConnect service daemon.
|
||||
*/
|
||||
const Service = GObject.registerClass({
|
||||
GTypeName: 'GSConnectService',
|
||||
}, class Service extends Gtk.Application {
|
||||
|
||||
_init() {
|
||||
super._init({
|
||||
application_id: 'org.gnome.Shell.Extensions.GSConnect',
|
||||
flags: Gio.ApplicationFlags.HANDLES_OPEN,
|
||||
resource_base_path: '/org/gnome/Shell/Extensions/GSConnect',
|
||||
});
|
||||
|
||||
GLib.set_prgname('gsconnect');
|
||||
GLib.set_application_name('GSConnect');
|
||||
|
||||
// Command-line
|
||||
this._initOptions();
|
||||
}
|
||||
|
||||
get settings() {
|
||||
if (this._settings === undefined) {
|
||||
this._settings = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
|
||||
});
|
||||
}
|
||||
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
/*
|
||||
* GActions
|
||||
*/
|
||||
_initActions() {
|
||||
const actions = [
|
||||
['connect', this._identify.bind(this), new GLib.VariantType('s')],
|
||||
['device', this._device.bind(this), new GLib.VariantType('(ssbv)')],
|
||||
['error', this._error.bind(this), new GLib.VariantType('a{ss}')],
|
||||
['preferences', this._preferences, null],
|
||||
['quit', () => this.quit(), null],
|
||||
['refresh', this._identify.bind(this), null],
|
||||
];
|
||||
|
||||
for (const [name, callback, type] of actions) {
|
||||
const action = new Gio.SimpleAction({
|
||||
name: name,
|
||||
parameter_type: type,
|
||||
});
|
||||
action.connect('activate', callback);
|
||||
this.add_action(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for Device GActions. This is used to route device notification
|
||||
* actions to their device, since GNotifications need an 'app' level action.
|
||||
*
|
||||
* @param {Gio.Action} action - The GAction
|
||||
* @param {GLib.Variant} parameter - The activation parameter
|
||||
*/
|
||||
_device(action, parameter) {
|
||||
try {
|
||||
parameter = parameter.unpack();
|
||||
|
||||
// Select the appropriate device(s)
|
||||
let devices;
|
||||
const id = parameter[0].unpack();
|
||||
|
||||
if (id === '*')
|
||||
devices = this.manager.devices.values();
|
||||
else
|
||||
devices = [this.manager.devices.get(id)];
|
||||
|
||||
// Unpack the action data and activate the action
|
||||
const name = parameter[1].unpack();
|
||||
const target = parameter[2].unpack() ? parameter[3].unpack() : null;
|
||||
|
||||
for (const device of devices)
|
||||
device.activate_action(name, target);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_error(action, parameter) {
|
||||
try {
|
||||
const error = parameter.deepUnpack();
|
||||
|
||||
// If there's a URL, we have better information in the Wiki
|
||||
if (error.url !== undefined) {
|
||||
Gio.AppInfo.launch_default_for_uri_async(
|
||||
error.url,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = new ServiceUI.ErrorDialog(error);
|
||||
dialog.present();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_identify(action, parameter) {
|
||||
try {
|
||||
let uri = null;
|
||||
|
||||
if (parameter instanceof GLib.Variant)
|
||||
uri = parameter.unpack();
|
||||
|
||||
this.manager.identify(uri);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_preferences() {
|
||||
Gio.Subprocess.new(
|
||||
[`${Config.PACKAGE_DATADIR}/gsconnect-preferences`],
|
||||
Gio.SubprocessFlags.NONE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a service-level error
|
||||
*
|
||||
* @param {Object} error - An Error or object with name, message and stack
|
||||
*/
|
||||
notify_error(error) {
|
||||
try {
|
||||
// Always log the error
|
||||
logError(error);
|
||||
|
||||
// Create an new notification
|
||||
let id, body, priority;
|
||||
const notif = new Gio.Notification();
|
||||
const icon = new Gio.ThemedIcon({name: 'dialog-error'});
|
||||
let target = null;
|
||||
|
||||
if (error.name === undefined)
|
||||
error.name = 'Error';
|
||||
|
||||
if (error.url !== undefined) {
|
||||
id = error.url;
|
||||
body = _('Click for help troubleshooting');
|
||||
priority = Gio.NotificationPriority.URGENT;
|
||||
|
||||
target = new GLib.Variant('a{ss}', {
|
||||
name: error.name.trim(),
|
||||
message: error.message.trim(),
|
||||
stack: error.stack.trim(),
|
||||
url: error.url,
|
||||
});
|
||||
} else {
|
||||
id = error.message.trim();
|
||||
body = _('Click for more information');
|
||||
priority = Gio.NotificationPriority.HIGH;
|
||||
|
||||
target = new GLib.Variant('a{ss}', {
|
||||
name: error.name.trim(),
|
||||
message: error.message.trim(),
|
||||
stack: error.stack.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
notif.set_title(`GSConnect: ${error.name.trim()}`);
|
||||
notif.set_body(body);
|
||||
notif.set_icon(icon);
|
||||
notif.set_priority(priority);
|
||||
notif.set_default_action_and_target('app.error', target);
|
||||
|
||||
this.send_notification(id, notif);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
vfunc_activate() {
|
||||
super.vfunc_activate();
|
||||
}
|
||||
|
||||
vfunc_startup() {
|
||||
super.vfunc_startup();
|
||||
|
||||
this.hold();
|
||||
|
||||
// Watch *this* file and stop the service if it's updated/uninstalled
|
||||
this._serviceMonitor = Gio.File.new_for_path(
|
||||
`${Config.PACKAGE_DATADIR}/service/daemon.js`
|
||||
).monitor(Gio.FileMonitorFlags.WATCH_MOVES, null);
|
||||
this._serviceMonitor.connect('changed', () => this.quit());
|
||||
|
||||
// Init some resources
|
||||
const provider = new Gtk.CssProvider();
|
||||
provider.load_from_resource(`${Config.APP_PATH}/application.css`);
|
||||
Gtk.StyleContext.add_provider_for_screen(
|
||||
Gdk.Screen.get_default(),
|
||||
provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
);
|
||||
|
||||
// Ensure our handlers are registered
|
||||
try {
|
||||
const appInfo = Gio.DesktopAppInfo.new(`${Config.APP_ID}.desktop`);
|
||||
appInfo.add_supports_type('x-scheme-handler/sms');
|
||||
appInfo.add_supports_type('x-scheme-handler/tel');
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
}
|
||||
|
||||
// GActions & GSettings
|
||||
this._initActions();
|
||||
|
||||
this.manager.start();
|
||||
}
|
||||
|
||||
vfunc_dbus_register(connection, object_path) {
|
||||
if (!super.vfunc_dbus_register(connection, object_path))
|
||||
return false;
|
||||
|
||||
this.manager = new Manager.Manager({
|
||||
connection: connection,
|
||||
object_path: object_path,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
vfunc_dbus_unregister(connection, object_path) {
|
||||
this.manager.destroy();
|
||||
|
||||
super.vfunc_dbus_unregister(connection, object_path);
|
||||
}
|
||||
|
||||
vfunc_open(files, hint) {
|
||||
super.vfunc_open(files, hint);
|
||||
|
||||
for (const file of files) {
|
||||
let action, parameter, title;
|
||||
|
||||
try {
|
||||
switch (file.get_uri_scheme()) {
|
||||
case 'sms':
|
||||
title = _('Send SMS');
|
||||
action = 'uriSms';
|
||||
parameter = new GLib.Variant('s', file.get_uri());
|
||||
break;
|
||||
|
||||
case 'tel':
|
||||
title = _('Dial Number');
|
||||
action = 'shareUri';
|
||||
parameter = new GLib.Variant('s', file.get_uri());
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
title = _('Share File');
|
||||
action = 'shareFile';
|
||||
parameter = new GLib.Variant('(sb)', [file.get_uri(), false]);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported URI: ${file.get_uri()}`);
|
||||
}
|
||||
|
||||
// Show chooser dialog
|
||||
new ServiceUI.DeviceChooser({
|
||||
title: title,
|
||||
action_name: action,
|
||||
action_target: parameter,
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, `GSConnect: Opening ${file.get_uri()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vfunc_shutdown() {
|
||||
// Dispose GSettings
|
||||
if (this._settings !== undefined)
|
||||
this.settings.run_dispose();
|
||||
|
||||
this.manager.stop();
|
||||
|
||||
// Exhaust the event loop to ensure any pending operations complete
|
||||
const context = GLib.MainContext.default();
|
||||
|
||||
while (context.iteration(false))
|
||||
continue;
|
||||
|
||||
// Force a GC to prevent any more calls back into JS, then chain-up
|
||||
imports.system.gc();
|
||||
super.vfunc_shutdown();
|
||||
}
|
||||
|
||||
/*
|
||||
* CLI
|
||||
*/
|
||||
_initOptions() {
|
||||
/*
|
||||
* Device Listings
|
||||
*/
|
||||
this.add_main_option(
|
||||
'list-devices',
|
||||
'l'.charCodeAt(0),
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('List available devices'),
|
||||
null
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'list-all',
|
||||
'a'.charCodeAt(0),
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('List all devices'),
|
||||
null
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'device',
|
||||
'd'.charCodeAt(0),
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Target Device'),
|
||||
'<device-id>'
|
||||
);
|
||||
|
||||
/**
|
||||
* Pairing
|
||||
*/
|
||||
this.add_main_option(
|
||||
'pair',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Pair'),
|
||||
null
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'unpair',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Unpair'),
|
||||
null
|
||||
);
|
||||
|
||||
/*
|
||||
* Messaging
|
||||
*/
|
||||
this.add_main_option(
|
||||
'message',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING_ARRAY,
|
||||
_('Send SMS'),
|
||||
'<phone-number>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'message-body',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Message Body'),
|
||||
'<text>'
|
||||
);
|
||||
|
||||
/*
|
||||
* Notifications
|
||||
*/
|
||||
this.add_main_option(
|
||||
'notification',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Send Notification'),
|
||||
'<title>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'notification-appname',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Notification App Name'),
|
||||
'<name>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'notification-body',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Notification Body'),
|
||||
'<text>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'notification-icon',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Notification Icon'),
|
||||
'<icon-name>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'notification-id',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Notification ID'),
|
||||
'<id>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'photo',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Photo'),
|
||||
null
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'ping',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Ping'),
|
||||
null
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'ring',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Ring'),
|
||||
null
|
||||
);
|
||||
|
||||
/*
|
||||
* Sharing
|
||||
*/
|
||||
this.add_main_option(
|
||||
'share-file',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.FILENAME_ARRAY,
|
||||
_('Share File'),
|
||||
'<filepath|URI>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'share-link',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING_ARRAY,
|
||||
_('Share Link'),
|
||||
'<URL>'
|
||||
);
|
||||
|
||||
this.add_main_option(
|
||||
'share-text',
|
||||
0,
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.STRING,
|
||||
_('Share Text'),
|
||||
'<text>'
|
||||
);
|
||||
|
||||
/*
|
||||
* Misc
|
||||
*/
|
||||
this.add_main_option(
|
||||
'version',
|
||||
'v'.charCodeAt(0),
|
||||
GLib.OptionFlags.NONE,
|
||||
GLib.OptionArg.NONE,
|
||||
_('Show release version'),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
_cliAction(id, name, parameter = null) {
|
||||
const parameters = [];
|
||||
|
||||
if (parameter instanceof GLib.Variant)
|
||||
parameters[0] = parameter;
|
||||
|
||||
id = id.replace(/\W+/g, '_');
|
||||
|
||||
Gio.DBus.session.call_sync(
|
||||
'org.gnome.Shell.Extensions.GSConnect',
|
||||
`/org/gnome/Shell/Extensions/GSConnect/Device/${id}`,
|
||||
'org.gtk.Actions',
|
||||
'Activate',
|
||||
GLib.Variant.new('(sava{sv})', [name, parameters, {}]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
_cliListDevices(full = true) {
|
||||
const result = Gio.DBus.session.call_sync(
|
||||
'org.gnome.Shell.Extensions.GSConnect',
|
||||
'/org/gnome/Shell/Extensions/GSConnect',
|
||||
'org.freedesktop.DBus.ObjectManager',
|
||||
'GetManagedObjects',
|
||||
null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
null
|
||||
);
|
||||
|
||||
const variant = result.unpack()[0].unpack();
|
||||
let device;
|
||||
|
||||
for (let object of Object.values(variant)) {
|
||||
object = object.recursiveUnpack();
|
||||
device = object['org.gnome.Shell.Extensions.GSConnect.Device'];
|
||||
|
||||
if (full)
|
||||
print(`${device.Id}\t${device.Name}\t${device.Connected}\t${device.Paired}`);
|
||||
else if (device.Connected && device.Paired)
|
||||
print(device.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_cliMessage(id, options) {
|
||||
if (!options.contains('message-body'))
|
||||
throw new TypeError('missing --message-body option');
|
||||
|
||||
// TODO: currently we only support single-recipient messaging
|
||||
const addresses = options.lookup_value('message', null).deepUnpack();
|
||||
const body = options.lookup_value('message-body', null).deepUnpack();
|
||||
|
||||
this._cliAction(
|
||||
id,
|
||||
'sendSms',
|
||||
GLib.Variant.new('(ss)', [addresses[0], body])
|
||||
);
|
||||
}
|
||||
|
||||
_cliNotify(id, options) {
|
||||
const title = options.lookup_value('notification', null).unpack();
|
||||
let body = '';
|
||||
let icon = null;
|
||||
let nid = `${Date.now()}`;
|
||||
let appName = 'GSConnect CLI';
|
||||
|
||||
if (options.contains('notification-id'))
|
||||
nid = options.lookup_value('notification-id', null).unpack();
|
||||
|
||||
if (options.contains('notification-body'))
|
||||
body = options.lookup_value('notification-body', null).unpack();
|
||||
|
||||
if (options.contains('notification-appname'))
|
||||
appName = options.lookup_value('notification-appname', null).unpack();
|
||||
|
||||
if (options.contains('notification-icon')) {
|
||||
icon = options.lookup_value('notification-icon', null).unpack();
|
||||
icon = Gio.Icon.new_for_string(icon);
|
||||
} else {
|
||||
icon = new Gio.ThemedIcon({
|
||||
name: 'org.gnome.Shell.Extensions.GSConnect',
|
||||
});
|
||||
}
|
||||
|
||||
const notification = new GLib.Variant('a{sv}', {
|
||||
appName: GLib.Variant.new_string(appName),
|
||||
id: GLib.Variant.new_string(nid),
|
||||
title: GLib.Variant.new_string(title),
|
||||
text: GLib.Variant.new_string(body),
|
||||
ticker: GLib.Variant.new_string(`${title}: ${body}`),
|
||||
time: GLib.Variant.new_string(`${Date.now()}`),
|
||||
isClearable: GLib.Variant.new_boolean(true),
|
||||
icon: icon.serialize(),
|
||||
});
|
||||
|
||||
this._cliAction(id, 'sendNotification', notification);
|
||||
}
|
||||
|
||||
_cliShareFile(device, options) {
|
||||
const files = options.lookup_value('share-file', null).deepUnpack();
|
||||
|
||||
for (let file of files) {
|
||||
file = imports.byteArray.toString(file);
|
||||
this._cliAction(device, 'shareFile', GLib.Variant.new('(sb)', [file, false]));
|
||||
}
|
||||
}
|
||||
|
||||
_cliShareLink(device, options) {
|
||||
const uris = options.lookup_value('share-link', null).unpack();
|
||||
|
||||
for (const uri of uris)
|
||||
this._cliAction(device, 'shareUri', uri);
|
||||
}
|
||||
|
||||
_cliShareText(device, options) {
|
||||
const text = options.lookup_value('share-text', null).unpack();
|
||||
|
||||
this._cliAction(device, 'shareText', GLib.Variant.new_string(text));
|
||||
}
|
||||
|
||||
vfunc_handle_local_options(options) {
|
||||
try {
|
||||
if (options.contains('version')) {
|
||||
print(`GSConnect ${Config.PACKAGE_VERSION}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.register(null);
|
||||
|
||||
if (options.contains('list-devices')) {
|
||||
this._cliListDevices(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.contains('list-all')) {
|
||||
this._cliListDevices(true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// We need a device for anything else; exit since this is probably
|
||||
// the daemon being started.
|
||||
if (!options.contains('device'))
|
||||
return -1;
|
||||
|
||||
const id = options.lookup_value('device', null).unpack();
|
||||
|
||||
// Pairing
|
||||
if (options.contains('pair')) {
|
||||
this._cliAction(id, 'pair');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.contains('unpair')) {
|
||||
this._cliAction(id, 'unpair');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Plugins
|
||||
if (options.contains('message'))
|
||||
this._cliMessage(id, options);
|
||||
|
||||
if (options.contains('notification'))
|
||||
this._cliNotify(id, options);
|
||||
|
||||
if (options.contains('photo'))
|
||||
this._cliAction(id, 'photo');
|
||||
|
||||
if (options.contains('ping'))
|
||||
this._cliAction(id, 'ping', GLib.Variant.new_string(''));
|
||||
|
||||
if (options.contains('ring'))
|
||||
this._cliAction(id, 'ring');
|
||||
|
||||
if (options.contains('share-file'))
|
||||
this._cliShareFile(id, options);
|
||||
|
||||
if (options.contains('share-link'))
|
||||
this._cliShareLink(id, options);
|
||||
|
||||
if (options.contains('share-text'))
|
||||
this._cliShareText(id, options);
|
||||
|
||||
return 0;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(new Service()).run([imports.system.programInvocationName].concat(ARGV));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const DBus = imports.service.utils.dbus;
|
||||
const Device = imports.service.device;
|
||||
|
||||
const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
|
||||
const DEVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect/Device';
|
||||
const DEVICE_IFACE = Config.DBUS.lookup_interface(DEVICE_NAME);
|
||||
|
||||
|
||||
/**
|
||||
* A manager for devices.
|
||||
*/
|
||||
var Manager = GObject.registerClass({
|
||||
GTypeName: 'GSConnectManager',
|
||||
Properties: {
|
||||
'active': GObject.ParamSpec.boolean(
|
||||
'active',
|
||||
'Active',
|
||||
'Whether the manager is active',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'discoverable': GObject.ParamSpec.boolean(
|
||||
'discoverable',
|
||||
'Discoverable',
|
||||
'Whether the service responds to discovery requests',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
false
|
||||
),
|
||||
'id': GObject.ParamSpec.string(
|
||||
'id',
|
||||
'Id',
|
||||
'The hostname or other network unique id',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
'name': GObject.ParamSpec.string(
|
||||
'name',
|
||||
'Name',
|
||||
'The name announced to the network',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
'GSConnect'
|
||||
),
|
||||
},
|
||||
}, class Manager extends Gio.DBusObjectManagerServer {
|
||||
|
||||
_init(params = {}) {
|
||||
super._init(params);
|
||||
|
||||
this._exported = new WeakMap();
|
||||
this._reconnectId = 0;
|
||||
|
||||
this._settings = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
|
||||
});
|
||||
this._initSettings();
|
||||
}
|
||||
|
||||
get active() {
|
||||
if (this._active === undefined)
|
||||
this._active = false;
|
||||
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get backends() {
|
||||
if (this._backends === undefined)
|
||||
this._backends = new Map();
|
||||
|
||||
return this._backends;
|
||||
}
|
||||
|
||||
get devices() {
|
||||
if (this._devices === undefined)
|
||||
this._devices = new Map();
|
||||
|
||||
return this._devices;
|
||||
}
|
||||
|
||||
get discoverable() {
|
||||
if (this._discoverable === undefined)
|
||||
this._discoverable = this.settings.get_boolean('discoverable');
|
||||
|
||||
return this._discoverable;
|
||||
}
|
||||
|
||||
set discoverable(value) {
|
||||
if (this.discoverable === value)
|
||||
return;
|
||||
|
||||
this._discoverable = value;
|
||||
this.notify('discoverable');
|
||||
|
||||
// FIXME: This whole thing just keeps getting uglier
|
||||
const application = Gio.Application.get_default();
|
||||
|
||||
if (application === null)
|
||||
return;
|
||||
|
||||
if (this.discoverable) {
|
||||
Gio.Application.prototype.withdraw_notification.call(
|
||||
application,
|
||||
'discovery-warning'
|
||||
);
|
||||
} else {
|
||||
const notif = new Gio.Notification();
|
||||
notif.set_title(_('Discovery Disabled'));
|
||||
notif.set_body(_('Discovery has been disabled due to the number of devices on this network.'));
|
||||
notif.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
|
||||
notif.set_priority(Gio.NotificationPriority.HIGH);
|
||||
notif.set_default_action('app.preferences');
|
||||
|
||||
Gio.Application.prototype.withdraw_notification.call(
|
||||
application,
|
||||
'discovery-warning',
|
||||
notif
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get id() {
|
||||
if (this._id === undefined)
|
||||
this._id = this.settings.get_string('id');
|
||||
|
||||
return this._id;
|
||||
}
|
||||
|
||||
set id(value) {
|
||||
if (this.id === value)
|
||||
return;
|
||||
|
||||
this._id = value;
|
||||
this.notify('id');
|
||||
}
|
||||
|
||||
get name() {
|
||||
if (this._name === undefined)
|
||||
this._name = this.settings.get_string('name');
|
||||
|
||||
return this._name;
|
||||
}
|
||||
|
||||
set name(value) {
|
||||
if (this.name === value)
|
||||
return;
|
||||
|
||||
this._name = value;
|
||||
this.notify('name');
|
||||
|
||||
// Broadcast changes to the network
|
||||
for (const backend of this.backends.values()) {
|
||||
backend.name = this.name;
|
||||
backend.buildIdentity();
|
||||
}
|
||||
|
||||
this.identify();
|
||||
}
|
||||
|
||||
get settings() {
|
||||
if (this._settings === undefined) {
|
||||
this._settings = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
|
||||
});
|
||||
}
|
||||
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
vfunc_notify(pspec) {
|
||||
if (pspec.name !== 'connection')
|
||||
return;
|
||||
|
||||
if (this.connection !== null)
|
||||
this._exportDevices();
|
||||
else
|
||||
this._unexportDevices();
|
||||
}
|
||||
|
||||
/*
|
||||
* GSettings
|
||||
*/
|
||||
_initSettings() {
|
||||
// Initialize the ID and name of the service
|
||||
if (this.settings.get_string('id').length === 0)
|
||||
this.settings.set_string('id', GLib.uuid_string_random());
|
||||
|
||||
if (this.settings.get_string('name').length === 0)
|
||||
this.settings.set_string('name', GLib.get_host_name());
|
||||
|
||||
// Bound Properties
|
||||
this.settings.bind('discoverable', this, 'discoverable', 0);
|
||||
this.settings.bind('id', this, 'id', 0);
|
||||
this.settings.bind('name', this, 'name', 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* Backends
|
||||
*/
|
||||
_onChannel(backend, channel) {
|
||||
try {
|
||||
let device = this.devices.get(channel.identity.body.deviceId);
|
||||
|
||||
switch (true) {
|
||||
// Proceed if this is an existing device...
|
||||
case (device !== undefined):
|
||||
break;
|
||||
|
||||
// Or the connection is allowed...
|
||||
case this.discoverable || channel.allowed:
|
||||
device = this._ensureDevice(channel.identity);
|
||||
break;
|
||||
|
||||
// ...otherwise bail
|
||||
default:
|
||||
debug(`${channel.identity.body.deviceName}: not allowed`);
|
||||
return false;
|
||||
}
|
||||
|
||||
device.setChannel(channel);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logError(e, backend.name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_loadBackends() {
|
||||
for (const name in imports.service.backends) {
|
||||
try {
|
||||
// Try to create the backend and track it if successful
|
||||
const module = imports.service.backends[name];
|
||||
const backend = new module.ChannelService({
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
});
|
||||
this.backends.set(name, backend);
|
||||
|
||||
// Connect to the backend
|
||||
backend.__channelId = backend.connect(
|
||||
'channel',
|
||||
this._onChannel.bind(this)
|
||||
);
|
||||
|
||||
// Now try to start the backend, allowing us to retry if we fail
|
||||
backend.start();
|
||||
} catch (e) {
|
||||
if (Gio.Application.get_default())
|
||||
Gio.Application.get_default().notify_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Devices
|
||||
*/
|
||||
_loadDevices() {
|
||||
// Load cached devices
|
||||
for (const id of this.settings.get_strv('devices')) {
|
||||
const device = new Device.Device({body: {deviceId: id}});
|
||||
this._exportDevice(device);
|
||||
this.devices.set(id, device);
|
||||
}
|
||||
}
|
||||
|
||||
_exportDevice(device) {
|
||||
if (this.connection === null)
|
||||
return;
|
||||
|
||||
const info = {
|
||||
object: null,
|
||||
interface: null,
|
||||
actions: 0,
|
||||
menu: 0,
|
||||
};
|
||||
|
||||
const objectPath = `${DEVICE_PATH}/${device.id.replace(/\W+/g, '_')}`;
|
||||
|
||||
// Export an object path for the device
|
||||
info.object = new Gio.DBusObjectSkeleton({
|
||||
g_object_path: objectPath,
|
||||
});
|
||||
this.export(info.object);
|
||||
|
||||
// Export GActions & GMenu
|
||||
info.actions = Gio.DBus.session.export_action_group(objectPath, device);
|
||||
info.menu = Gio.DBus.session.export_menu_model(objectPath, device.menu);
|
||||
|
||||
// Export the Device interface
|
||||
info.interface = new DBus.Interface({
|
||||
g_instance: device,
|
||||
g_interface_info: DEVICE_IFACE,
|
||||
});
|
||||
info.object.add_interface(info.interface);
|
||||
|
||||
this._exported.set(device, info);
|
||||
}
|
||||
|
||||
_exportDevices() {
|
||||
if (this.connection === null)
|
||||
return;
|
||||
|
||||
for (const device of this.devices.values())
|
||||
this._exportDevice(device);
|
||||
}
|
||||
|
||||
_unexportDevice(device) {
|
||||
const info = this._exported.get(device);
|
||||
|
||||
if (info === undefined)
|
||||
return;
|
||||
|
||||
// Unexport GActions and GMenu
|
||||
Gio.DBus.session.unexport_action_group(info.actions);
|
||||
Gio.DBus.session.unexport_menu_model(info.menu);
|
||||
|
||||
// Unexport the Device interface and object
|
||||
info.interface.flush();
|
||||
info.object.remove_interface(info.interface);
|
||||
info.object.flush();
|
||||
this.unexport(info.object.g_object_path);
|
||||
|
||||
this._exported.delete(device);
|
||||
}
|
||||
|
||||
_unexportDevices() {
|
||||
for (const device of this.devices.values())
|
||||
this._unexportDevice(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a device for @packet, creating it and adding it to the list of
|
||||
* of known devices if it doesn't exist.
|
||||
*
|
||||
* @param {Core.Packet} packet - An identity packet for the device
|
||||
* @return {Device.Device} A device object
|
||||
*/
|
||||
_ensureDevice(packet) {
|
||||
let device = this.devices.get(packet.body.deviceId);
|
||||
|
||||
if (device === undefined) {
|
||||
debug(`Adding ${packet.body.deviceName}`);
|
||||
|
||||
// TODO: Remove when all clients support bluetooth-like discovery
|
||||
//
|
||||
// If this is the third unpaired device to connect, we disable
|
||||
// discovery to avoid choking on networks with many devices
|
||||
const unpaired = Array.from(this.devices.values()).filter(dev => {
|
||||
return !dev.paired;
|
||||
});
|
||||
|
||||
if (unpaired.length === 3)
|
||||
this.discoverable = false;
|
||||
|
||||
device = new Device.Device(packet);
|
||||
this._exportDevice(device);
|
||||
this.devices.set(device.id, device);
|
||||
|
||||
// Notify
|
||||
this.settings.set_strv('devices', Array.from(this.devices.keys()));
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently remove a device.
|
||||
*
|
||||
* Removes the device from the list of known devices, deletes all GSettings
|
||||
* and files.
|
||||
*
|
||||
* @param {string} id - The id of the device to delete
|
||||
*/
|
||||
_removeDevice(id) {
|
||||
// Delete all GSettings
|
||||
const settings_path = `/org/gnome/shell/extensions/gsconnect/${id}/`;
|
||||
GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`);
|
||||
|
||||
// Delete the cache
|
||||
const cache = GLib.build_filenamev([Config.CACHEDIR, id]);
|
||||
Gio.File.rm_rf(cache);
|
||||
|
||||
// Forget the device
|
||||
this.devices.delete(id);
|
||||
this.settings.set_strv('devices', Array.from(this.devices.keys()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A GSourceFunc that tries to reconnect to each paired device, while
|
||||
* pruning unpaired devices that have disconnected.
|
||||
*
|
||||
* @return {boolean} Always %true
|
||||
*/
|
||||
_reconnect() {
|
||||
for (const [id, device] of this.devices) {
|
||||
if (device.connected)
|
||||
continue;
|
||||
|
||||
if (device.paired) {
|
||||
this.identify(device.settings.get_string('last-connection'));
|
||||
continue;
|
||||
}
|
||||
|
||||
this._unexportDevice(device);
|
||||
this._removeDevice(id);
|
||||
device.destroy();
|
||||
}
|
||||
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify to an address or broadcast to the network.
|
||||
*
|
||||
* @param {string} [uri] - An address URI or %null to broadcast
|
||||
*/
|
||||
identify(uri = null) {
|
||||
try {
|
||||
// If we're passed a parameter, try and find a backend for it
|
||||
if (uri !== null) {
|
||||
const [scheme, address] = uri.split('://');
|
||||
|
||||
const backend = this.backends.get(scheme);
|
||||
|
||||
if (backend !== undefined)
|
||||
backend.broadcast(address);
|
||||
|
||||
// If we're not discoverable, only try to reconnect known devices
|
||||
} else if (!this.discoverable) {
|
||||
this._reconnect();
|
||||
|
||||
// Otherwise have each backend broadcast to it's network
|
||||
} else {
|
||||
this.backends.forEach(backend => backend.broadcast());
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start managing devices.
|
||||
*/
|
||||
start() {
|
||||
if (this.active)
|
||||
return;
|
||||
|
||||
this._loadDevices();
|
||||
this._loadBackends();
|
||||
|
||||
if (this._reconnectId === 0) {
|
||||
this._reconnectId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_LOW,
|
||||
5,
|
||||
this._reconnect.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
this._active = true;
|
||||
this.notify('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop managing devices.
|
||||
*/
|
||||
stop() {
|
||||
if (!this.active)
|
||||
return;
|
||||
|
||||
if (this._reconnectId > 0) {
|
||||
GLib.Source.remove(this._reconnectId);
|
||||
this._reconnectId = 0;
|
||||
}
|
||||
|
||||
this._unexportDevices();
|
||||
|
||||
this.backends.forEach(backend => backend.destroy());
|
||||
this.backends.clear();
|
||||
|
||||
this.devices.forEach(device => device.destroy());
|
||||
this.devices.clear();
|
||||
|
||||
this._active = false;
|
||||
this.notify('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop managing devices and free any resources.
|
||||
*/
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.set_connection(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env gjs
|
||||
|
||||
'use strict';
|
||||
|
||||
imports.gi.versions.Gio = '2.0';
|
||||
imports.gi.versions.GLib = '2.0';
|
||||
imports.gi.versions.GObject = '2.0';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const System = imports.system;
|
||||
|
||||
|
||||
const NativeMessagingHost = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNativeMessagingHost',
|
||||
}, class NativeMessagingHost extends Gio.Application {
|
||||
|
||||
_init() {
|
||||
super._init({
|
||||
application_id: 'org.gnome.Shell.Extensions.GSConnect.NativeMessagingHost',
|
||||
flags: Gio.ApplicationFlags.NON_UNIQUE,
|
||||
});
|
||||
}
|
||||
|
||||
get devices() {
|
||||
if (this._devices === undefined)
|
||||
this._devices = {};
|
||||
|
||||
return this._devices;
|
||||
}
|
||||
|
||||
vfunc_activate() {
|
||||
super.vfunc_activate();
|
||||
}
|
||||
|
||||
vfunc_startup() {
|
||||
super.vfunc_startup();
|
||||
this.hold();
|
||||
|
||||
// IO Channels
|
||||
this._stdin = new Gio.DataInputStream({
|
||||
base_stream: new Gio.UnixInputStream({fd: 0}),
|
||||
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN,
|
||||
});
|
||||
|
||||
this._stdout = new Gio.DataOutputStream({
|
||||
base_stream: new Gio.UnixOutputStream({fd: 1}),
|
||||
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN,
|
||||
});
|
||||
|
||||
const source = this._stdin.base_stream.create_source(null);
|
||||
source.set_callback(this.receive.bind(this));
|
||||
source.attach(null);
|
||||
|
||||
// Device Manager
|
||||
try {
|
||||
this._manager = Gio.DBusObjectManagerClient.new_for_bus_sync(
|
||||
Gio.BusType.SESSION,
|
||||
Gio.DBusObjectManagerClientFlags.DO_NOT_AUTO_START,
|
||||
'org.gnome.Shell.Extensions.GSConnect',
|
||||
'/org/gnome/Shell/Extensions/GSConnect',
|
||||
null,
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
this.quit();
|
||||
}
|
||||
|
||||
// Add currently managed devices
|
||||
for (const object of this._manager.get_objects()) {
|
||||
for (const iface of object.get_interfaces())
|
||||
this._onInterfaceAdded(this._manager, object, iface);
|
||||
}
|
||||
|
||||
// Watch for new and removed devices
|
||||
this._manager.connect(
|
||||
'interface-added',
|
||||
this._onInterfaceAdded.bind(this)
|
||||
);
|
||||
this._manager.connect(
|
||||
'object-removed',
|
||||
this._onObjectRemoved.bind(this)
|
||||
);
|
||||
|
||||
// Watch for device property changes
|
||||
this._manager.connect(
|
||||
'interface-proxy-properties-changed',
|
||||
this.sendDeviceList.bind(this)
|
||||
);
|
||||
|
||||
// Watch for service restarts
|
||||
this._manager.connect(
|
||||
'notify::name-owner',
|
||||
this.sendDeviceList.bind(this)
|
||||
);
|
||||
|
||||
this.send({
|
||||
type: 'connected',
|
||||
data: (this._manager.name_owner !== null),
|
||||
});
|
||||
}
|
||||
|
||||
receive() {
|
||||
try {
|
||||
// Read the message
|
||||
const length = this._stdin.read_int32(null);
|
||||
const bytes = this._stdin.read_bytes(length, null).toArray();
|
||||
const message = JSON.parse(imports.byteArray.toString(bytes));
|
||||
|
||||
// A request for a list of devices
|
||||
if (message.type === 'devices') {
|
||||
this.sendDeviceList();
|
||||
|
||||
// A request to invoke an action
|
||||
} else if (message.type === 'share') {
|
||||
let actionName;
|
||||
const device = this.devices[message.data.device];
|
||||
|
||||
if (device) {
|
||||
if (message.data.action === 'share')
|
||||
actionName = 'shareUri';
|
||||
else if (message.data.action === 'telephony')
|
||||
actionName = 'shareSms';
|
||||
|
||||
device.actions.activate_action(
|
||||
actionName,
|
||||
new GLib.Variant('s', message.data.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
} catch (e) {
|
||||
this.quit();
|
||||
}
|
||||
}
|
||||
|
||||
send(message) {
|
||||
try {
|
||||
const data = JSON.stringify(message);
|
||||
this._stdout.put_int32(data.length, null);
|
||||
this._stdout.put_string(data, null);
|
||||
} catch (e) {
|
||||
this.quit();
|
||||
}
|
||||
}
|
||||
|
||||
sendDeviceList() {
|
||||
// Inform the WebExtension we're disconnected from the service
|
||||
if (this._manager && this._manager.name_owner === null)
|
||||
return this.send({type: 'connected', data: false});
|
||||
|
||||
// Collect all the devices with supported actions
|
||||
const available = [];
|
||||
|
||||
for (const device of Object.values(this.devices)) {
|
||||
const share = device.actions.get_action_enabled('shareUri');
|
||||
const telephony = device.actions.get_action_enabled('shareSms');
|
||||
|
||||
if (share || telephony) {
|
||||
available.push({
|
||||
id: device.g_object_path,
|
||||
name: device.name,
|
||||
type: device.type,
|
||||
share: share,
|
||||
telephony: telephony,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.send({type: 'devices', data: available});
|
||||
}
|
||||
|
||||
_proxyGetter(name) {
|
||||
try {
|
||||
return this.get_cached_property(name).unpack();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_onInterfaceAdded(manager, object, iface) {
|
||||
Object.defineProperties(iface, {
|
||||
'name': {
|
||||
get: this._proxyGetter.bind(iface, 'Name'),
|
||||
enumerable: true,
|
||||
},
|
||||
// TODO: phase this out for icon-name
|
||||
'type': {
|
||||
get: this._proxyGetter.bind(iface, 'Type'),
|
||||
enumerable: true,
|
||||
},
|
||||
});
|
||||
|
||||
iface.actions = Gio.DBusActionGroup.get(
|
||||
iface.g_connection,
|
||||
iface.g_name,
|
||||
iface.g_object_path
|
||||
);
|
||||
|
||||
this.devices[iface.g_object_path] = iface;
|
||||
this.sendDeviceList();
|
||||
}
|
||||
|
||||
_onObjectRemoved(manager, object) {
|
||||
delete this.devices[object.g_object_path];
|
||||
this.sendDeviceList();
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: must not pass ARGV
|
||||
(new NativeMessagingHost()).run([System.programInvocationName]);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
const ByteArray = imports.byteArray;
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
|
||||
/**
|
||||
* Base class for device plugins.
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectPlugin',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device that owns this plugin',
|
||||
GObject.ParamFlags.READABLE,
|
||||
GObject.Object
|
||||
),
|
||||
'name': GObject.ParamSpec.string(
|
||||
'name',
|
||||
'Name',
|
||||
'The device name',
|
||||
GObject.ParamFlags.READABLE,
|
||||
null
|
||||
),
|
||||
},
|
||||
}, class Plugin extends GObject.Object {
|
||||
|
||||
_init(device, name, meta = null) {
|
||||
super._init();
|
||||
|
||||
this._device = device;
|
||||
this._name = name;
|
||||
this._meta = meta;
|
||||
|
||||
if (this._meta === null)
|
||||
this._meta = imports.service.plugins[name].Metadata;
|
||||
|
||||
// GSettings
|
||||
const schema = Config.GSCHEMA.lookup(this._meta.id, false);
|
||||
|
||||
if (schema !== null) {
|
||||
this.settings = new Gio.Settings({
|
||||
settings_schema: schema,
|
||||
path: `${device.settings.path}plugin/${name}/`,
|
||||
});
|
||||
}
|
||||
|
||||
// GActions
|
||||
this._gactions = [];
|
||||
|
||||
if (this._meta.actions) {
|
||||
const menu = this.device.settings.get_strv('menu-actions');
|
||||
|
||||
for (const name in this._meta.actions) {
|
||||
const info = this._meta.actions[name];
|
||||
this._registerAction(name, menu.indexOf(name), info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get cancellable() {
|
||||
if (this._cancellable === undefined)
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
|
||||
return this._cancellable;
|
||||
}
|
||||
|
||||
get device() {
|
||||
return this._device;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
_activateAction(action, parameter) {
|
||||
try {
|
||||
let args = null;
|
||||
|
||||
if (parameter instanceof GLib.Variant)
|
||||
args = parameter.full_unpack();
|
||||
|
||||
if (Array.isArray(args))
|
||||
this[action.name](...args);
|
||||
else
|
||||
this[action.name](args);
|
||||
} catch (e) {
|
||||
logError(e, action.name);
|
||||
}
|
||||
}
|
||||
|
||||
_registerAction(name, menuIndex, info) {
|
||||
try {
|
||||
// Device Action
|
||||
const action = new Gio.SimpleAction({
|
||||
name: name,
|
||||
parameter_type: info.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,
|
||||
info.label,
|
||||
info.icon_name
|
||||
);
|
||||
}
|
||||
|
||||
this._gactions.push(action);
|
||||
} catch (e) {
|
||||
logError(e, `${this.device.name}: ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the device connects.
|
||||
*/
|
||||
connected() {
|
||||
// Enabled based on device capabilities, which might change
|
||||
const incoming = this.device.settings.get_strv('incoming-capabilities');
|
||||
const outgoing = this.device.settings.get_strv('outgoing-capabilities');
|
||||
|
||||
for (const action of this._gactions) {
|
||||
const info = this._meta.actions[action.name];
|
||||
|
||||
if (info.incoming.every(type => outgoing.includes(type)) &&
|
||||
info.outgoing.every(type => incoming.includes(type)))
|
||||
action.set_enabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the device disconnects.
|
||||
*/
|
||||
disconnected() {
|
||||
for (const action of this._gactions)
|
||||
action.set_enabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a packet is received that the plugin is a handler for.
|
||||
*
|
||||
* @param {Core.Packet} packet - A KDE Connect packet
|
||||
*/
|
||||
handlePacket(packet) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._cacheProperties = names;
|
||||
|
||||
// Ensure the device's cache directory exists
|
||||
const cachedir = GLib.build_filenamev([
|
||||
Config.CACHEDIR,
|
||||
this.device.id,
|
||||
]);
|
||||
GLib.mkdir_with_parents(cachedir, 448);
|
||||
|
||||
this._cacheFile = Gio.File.new_for_path(
|
||||
GLib.build_filenamev([cachedir, `${this.name}.json`])
|
||||
);
|
||||
|
||||
// Read the cache from disk
|
||||
await new Promise((resolve, reject) => {
|
||||
this._cacheFile.load_contents_async(null, (file, res) => {
|
||||
try {
|
||||
const contents = file.load_contents_finish(res)[1];
|
||||
const cache = JSON.parse(ByteArray.toString(contents));
|
||||
Object.assign(this, cache);
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} 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() {}
|
||||
|
||||
/**
|
||||
* Unregister plugin actions, write the cache (if applicable) and destroy
|
||||
* any dangling signal handlers.
|
||||
*/
|
||||
destroy() {
|
||||
// Cancel any pending plugin operations
|
||||
if (this._cancellable !== undefined)
|
||||
this._cancellable.cancel();
|
||||
|
||||
for (const action of this._gactions) {
|
||||
this.device.removeMenuAction(`device.${action.name}`);
|
||||
this.device.remove_action(action.name);
|
||||
}
|
||||
|
||||
// Write the cache to disk synchronously
|
||||
if (this._cacheFile !== undefined) {
|
||||
try {
|
||||
// Build the cache
|
||||
const cache = {};
|
||||
|
||||
for (const name of this._cacheProperties)
|
||||
cache[name] = this[name];
|
||||
|
||||
this._cacheFile.replace_contents(
|
||||
JSON.stringify(cache, null, 2),
|
||||
null,
|
||||
false,
|
||||
Gio.FileCreateFlags.REPLACE_DESTINATION,
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
debug(e.message, `${this.device.name}: ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
GObject.signal_handlers_destroy(this);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Battery'),
|
||||
description: _('Exchange battery information'),
|
||||
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 PluginBase.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._upower = null;
|
||||
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';
|
||||
|
||||
if (this.charging)
|
||||
return `${icon}-charging-symbolic`;
|
||||
|
||||
return `${icon}-symbolic`;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
this._initEstimate();
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._chargeState = [54, 0, -1];
|
||||
this._dischargeState = [864, 0, -1];
|
||||
this._thresholdLevel = 25;
|
||||
this._initEstimate();
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestState();
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.battery':
|
||||
this._receiveState(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.battery.request':
|
||||
this._sendState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onSendStatisticsChanged() {
|
||||
if (this.settings.get_boolean('send-statistics'))
|
||||
this._monitorState();
|
||||
else
|
||||
this._unmonitorState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate and update the estimated time remaining, but not the rate.
|
||||
*/
|
||||
_initEstimate() {
|
||||
let rate, level;
|
||||
|
||||
// elision of [rate, time, level]
|
||||
if (this.charging)
|
||||
[rate,, level] = this._chargeState;
|
||||
else
|
||||
[rate,, level] = this._dischargeState;
|
||||
|
||||
if (!Number.isFinite(rate) || rate < 1)
|
||||
rate = this.charging ? 864 : 90;
|
||||
|
||||
if (!Number.isFinite(level) || level < 0)
|
||||
level = this.level;
|
||||
|
||||
// Update the time remaining
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the (dis)charge rate and update the estimated time remaining.
|
||||
*/
|
||||
_updateEstimate() {
|
||||
let rate, time, level;
|
||||
const newTime = Math.floor(Date.now() / 1000);
|
||||
const newLevel = this.level;
|
||||
|
||||
// Load the state; ensure we have sane values for calculation
|
||||
if (this.charging)
|
||||
[rate, time, level] = this._chargeState;
|
||||
else
|
||||
[rate, time, level] = this._dischargeState;
|
||||
|
||||
if (!Number.isFinite(rate) || rate < 1)
|
||||
rate = this.charging ? 54 : 864;
|
||||
|
||||
if (!Number.isFinite(time) || time <= 0)
|
||||
time = newTime;
|
||||
|
||||
if (!Number.isFinite(level) || level < 0)
|
||||
level = newLevel;
|
||||
|
||||
// Update the rate; use a weighted average to account for missed changes
|
||||
// NOTE: (rate = seconds/percent)
|
||||
const ldiff = this.charging ? newLevel - level : level - newLevel;
|
||||
const tdiff = newTime - time;
|
||||
const newRate = tdiff / ldiff;
|
||||
|
||||
if (newRate && Number.isFinite(newRate))
|
||||
rate = Math.floor((rate * 0.4) + (newRate * 0.6));
|
||||
|
||||
// Store the state for the next recalculation
|
||||
if (this.charging)
|
||||
this._chargeState = [rate, newTime, newLevel];
|
||||
else
|
||||
this._dischargeState = [rate, newTime, newLevel];
|
||||
|
||||
// Update the time remaining
|
||||
if (rate && this.charging)
|
||||
this._time = Math.floor(rate * (100 - newLevel));
|
||||
else if (rate && !this.charging)
|
||||
this._time = Math.floor(rate * newLevel);
|
||||
|
||||
this.__state.state = this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is full.
|
||||
*/
|
||||
_fullBatteryNotification() {
|
||||
if (!this.settings.get_boolean('full-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|full',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery is full
|
||||
title: _('%s: Battery is full').format(this.device.name),
|
||||
// TRANSLATORS: when the battery is fully charged
|
||||
body: _('Fully Charged'),
|
||||
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is at custom charge level.
|
||||
*/
|
||||
_customBatteryNotification() {
|
||||
if (!this.settings.get_boolean('custom-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|custom',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery has reached custom charge level
|
||||
title: _('%s: Battery has reached custom charge level').format(this.device.name),
|
||||
// TRANSLATORS: when the battery has reached custom charge level
|
||||
body: _('%d%% Charged').format(this.level),
|
||||
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is low.
|
||||
*/
|
||||
_lowBatteryNotification() {
|
||||
if (!this.settings.get_boolean('low-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|low',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery is low
|
||||
title: _('%s: Battery is low').format(this.device.name),
|
||||
// TRANSLATORS: eg. 15% remaining
|
||||
body: _('%d%% remaining').format(this.level),
|
||||
icon: Gio.ThemedIcon.new('battery-caution-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote battery update.
|
||||
*
|
||||
* @param {Core.Packet} 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');
|
||||
|
||||
// The level just changed to/from custom level while charging
|
||||
if ((this._level === this.settings.get_uint('custom-battery-notification-value')) && this._charging)
|
||||
this._customBatteryNotification();
|
||||
else
|
||||
this.device.hideNotification('battery|custom');
|
||||
|
||||
// The level just changed to/from full
|
||||
if (this._level === 100)
|
||||
this._fullBatteryNotification();
|
||||
else
|
||||
this.device.hideNotification('battery|full');
|
||||
}
|
||||
|
||||
// Device considers the level low
|
||||
if (packet.body.thresholdEvent > 0) {
|
||||
this._lowBatteryNotification();
|
||||
this._thresholdLevel = this.level;
|
||||
}
|
||||
|
||||
this._updateEstimate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote battery's current state
|
||||
*/
|
||||
_requestState() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.battery.request',
|
||||
body: {request: true},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the local battery's current state
|
||||
*/
|
||||
_sendState() {
|
||||
if (this._upower === null || !this._upower.is_present)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.battery',
|
||||
body: {
|
||||
currentCharge: this._upower.level,
|
||||
isCharging: this._upower.charging,
|
||||
thresholdEvent: this._upower.threshold,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* UPower monitoring methods
|
||||
*/
|
||||
_monitorState() {
|
||||
try {
|
||||
// Currently only true if the remote device is a desktop (rare)
|
||||
const incoming = this.device.settings.get_strv('incoming-capabilities');
|
||||
|
||||
if (!incoming.includes('kdeconnect.battery'))
|
||||
return;
|
||||
|
||||
this._upower = Components.acquire('upower');
|
||||
|
||||
this._upowerId = this._upower.connect(
|
||||
'changed',
|
||||
this._sendState.bind(this)
|
||||
);
|
||||
|
||||
this._sendState();
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
this._unmonitorState();
|
||||
}
|
||||
}
|
||||
|
||||
_unmonitorState() {
|
||||
try {
|
||||
if (this._upower === null)
|
||||
return;
|
||||
|
||||
this._upower.disconnect(this._upowerId);
|
||||
this._upower = Components.release('upower');
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.device.remove_action('battery');
|
||||
this.settings.disconnect(this._sendStatisticsId);
|
||||
this._unmonitorState();
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Clipboard'),
|
||||
description: _('Share the clipboard content'),
|
||||
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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'clipboard');
|
||||
|
||||
this._clipboard = Components.acquire('clipboard');
|
||||
|
||||
// Watch local clipboard for changes
|
||||
this._textChangedId = this._clipboard.connect(
|
||||
'notify::text',
|
||||
this._onLocalClipboardChanged.bind(this)
|
||||
);
|
||||
|
||||
// Buffer content to allow selective sync
|
||||
this._localBuffer = this._clipboard.text;
|
||||
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 === 0)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.clipboard.connect',
|
||||
body: {
|
||||
content: this._localBuffer,
|
||||
timestamp: this._localTimestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
if (!packet.body.hasOwnProperty('content'))
|
||||
return;
|
||||
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.clipboard':
|
||||
this._handleContent(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.clipboard.connect':
|
||||
this._handleConnectContent(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_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 local 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 remote 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 === 0)
|
||||
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);
|
||||
this._clipboard = Components.release('clipboard');
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Connectivity Report'),
|
||||
description: _('Display connectivity status'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.ConnectivityReport',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.connectivity_report',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.connectivity_report.request',
|
||||
],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Connectivity Report Plugin
|
||||
* https://invent.kde.org/network/kdeconnect-kde/-/tree/master/plugins/connectivity_report
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectConnectivityReportPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'connectivity_report');
|
||||
|
||||
// Export connectivity state as GAction
|
||||
this.__state = new Gio.SimpleAction({
|
||||
name: 'connectivityReport',
|
||||
// (
|
||||
// cellular_network_type,
|
||||
// cellular_network_type_icon,
|
||||
// cellular_network_strength(0..4),
|
||||
// cellular_network_strength_icon,
|
||||
// )
|
||||
parameter_type: new GLib.VariantType('(ssis)'),
|
||||
state: this.state,
|
||||
});
|
||||
this.device.add_action(this.__state);
|
||||
}
|
||||
|
||||
get signal_strength() {
|
||||
if (this._signalStrength === undefined)
|
||||
this._signalStrength = -1;
|
||||
|
||||
return this._signalStrength;
|
||||
}
|
||||
|
||||
get network_type() {
|
||||
if (this._networkType === undefined)
|
||||
this._networkType = '';
|
||||
|
||||
return this._networkType;
|
||||
}
|
||||
|
||||
get signal_strength_icon_name() {
|
||||
if (this.signal_strength === 0)
|
||||
return 'network-cellular-signal-none-symbolic'; // SIGNAL_STRENGTH_NONE_OR_UNKNOWN
|
||||
else if (this.signal_strength === 1)
|
||||
return 'network-cellular-signal-weak-symbolic'; // SIGNAL_STRENGTH_POOR
|
||||
else if (this.signal_strength === 2)
|
||||
return 'network-cellular-signal-ok-symbolic'; // SIGNAL_STRENGTH_MODERATE
|
||||
else if (this.signal_strength === 3)
|
||||
return 'network-cellular-signal-good-symbolic'; // SIGNAL_STRENGTH_GOOD
|
||||
else if (this.signal_strength >= 4)
|
||||
return 'network-cellular-signal-excellent-symbolic'; // SIGNAL_STRENGTH_GREAT
|
||||
|
||||
return 'network-cellular-offline-symbolic'; // OFF (signal_strength == -1)
|
||||
}
|
||||
|
||||
get network_type_icon_name() {
|
||||
if (this.network_type === 'GSM' || this.network_type === 'CDMA' || this.network_type === 'iDEN')
|
||||
return 'network-cellular-2g-symbolic';
|
||||
else if (this.network_type === 'UMTS' || this.network_type === 'CDMA2000')
|
||||
return 'network-cellular-3g-symbolic';
|
||||
else if (this.network_type === 'LTE')
|
||||
return 'network-cellular-4g-symbolic';
|
||||
else if (this.network_type === 'EDGE')
|
||||
return 'network-cellular-edge-symbolic';
|
||||
else if (this.network_type === 'GPRS')
|
||||
return 'network-cellular-gprs-symbolic';
|
||||
else if (this.network_type === 'HSPA')
|
||||
return 'network-cellular-hspa-symbolic';
|
||||
// FIXME: No icon for this!
|
||||
// https://gitlab.gnome.org/GNOME/adwaita-icon-theme/-/issues/114
|
||||
else if (this.network_type === '5G')
|
||||
return 'network-cellular-symbolic';
|
||||
|
||||
return 'network-cellular-symbolic';
|
||||
}
|
||||
|
||||
get state() {
|
||||
return new GLib.Variant(
|
||||
'(ssis)',
|
||||
[
|
||||
this.network_type,
|
||||
this.network_type_icon_name,
|
||||
this.signal_strength,
|
||||
this.signal_strength_icon_name,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestState();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.connectivity_report':
|
||||
this._receiveState(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote state update.
|
||||
*
|
||||
* @param {Core.Packet} packet - A kdeconnect.connectivity_report packet
|
||||
*/
|
||||
_receiveState(packet) {
|
||||
if (packet.body.signalStrengths) {
|
||||
// TODO: Only first SIM (subscriptionID) is supported at the moment
|
||||
const subs = Object.keys(packet.body.signalStrengths);
|
||||
const firstSub = Math.min.apply(null, subs);
|
||||
const data = packet.body.signalStrengths[firstSub];
|
||||
|
||||
this._networkType = data.networkType;
|
||||
this._signalStrength = data.signalStrength;
|
||||
}
|
||||
|
||||
// Update DBus state
|
||||
this.__state.state = this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote device's connectivity state
|
||||
*/
|
||||
_requestState() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.connectivity_report.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.device.remove_action('connectivity_report');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
'use strict';
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
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'),
|
||||
description: _('Access contacts of the paired device'),
|
||||
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 PluginBase.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();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._store.clear();
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
this._requestUids();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.contacts.response_uids_timestamps':
|
||||
this._handleUids(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.contacts.response_vcards':
|
||||
this._handleVCards(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_handleUids(packet) {
|
||||
try {
|
||||
const contacts = this._store.contacts;
|
||||
const 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++) {
|
||||
const 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
|
||||
const uids = [];
|
||||
|
||||
for (const [uid, timestamp] of Object.entries(packet.body)) {
|
||||
const 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
|
||||
*/
|
||||
_decodeQuotedPrintable(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) => {
|
||||
const 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
|
||||
*/
|
||||
_decodeUTF8(input) {
|
||||
try {
|
||||
const 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 &= 0x7F;
|
||||
seqlen = 1;
|
||||
} else if (c1 <= 0xDF) {
|
||||
c1 &= 0x1F;
|
||||
seqlen = 2;
|
||||
} else if (c1 <= 0xEF) {
|
||||
c1 &= 0x0F;
|
||||
seqlen = 3;
|
||||
} else {
|
||||
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 only) and return a dictionary of the fields
|
||||
*
|
||||
* See: http://jsfiddle.net/ARTsinn/P2t2P/
|
||||
*
|
||||
* @param {string} vcard_data - The raw VCard data
|
||||
* @return {Object} dictionary of vCard data
|
||||
*/
|
||||
_parseVCard21(vcard_data) {
|
||||
// vcard skeleton
|
||||
const vcard = {
|
||||
fn: _('Unknown Contact'),
|
||||
tel: [],
|
||||
};
|
||||
|
||||
// Remove line folding and split
|
||||
const unfolded = vcard_data.replace(VCARD_FOLDING, '');
|
||||
const lines = unfolded.split(/\r\n|\r|\n/);
|
||||
|
||||
for (let i = 0, len = lines.length; i < len; i++) {
|
||||
const 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))) {
|
||||
[, key, value] = results;
|
||||
vcard[key.toLowerCase()] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Typed Fields (tel, adr, etc)
|
||||
if ((results = line.match(VCARD_TYPED))) {
|
||||
[, key, type, value] = results;
|
||||
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
|
||||
value = value.split(';');
|
||||
type = type.split(';');
|
||||
|
||||
// Type(s)
|
||||
const meta = {};
|
||||
|
||||
for (let i = 0, len = type.length; i < len; i++) {
|
||||
const 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._decodeQuotedPrintable(v));
|
||||
}
|
||||
|
||||
// Decode UTF-8
|
||||
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
|
||||
delete meta.CHARSET;
|
||||
value = value.map(v => this._decodeUTF8(v));
|
||||
}
|
||||
|
||||
// Special case for FN (full name)
|
||||
if (key === 'fn')
|
||||
vcard[key] = value[0];
|
||||
else
|
||||
vcard[key].push({meta: meta, value: value});
|
||||
}
|
||||
}
|
||||
|
||||
return vcard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vCard (v2.1 only) using native JavaScript and add it to the
|
||||
* contact store.
|
||||
*
|
||||
* @param {string} uid - The contact UID
|
||||
* @param {string} vcard_data - The raw vCard data
|
||||
*/
|
||||
async _parseVCardNative(uid, vcard_data) {
|
||||
try {
|
||||
const vcard = this._parseVCard21(vcard_data);
|
||||
|
||||
const 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) {
|
||||
const data = GLib.base64_decode(vcard.photo[0].value[0]);
|
||||
contact.avatar = await this._store.storeAvatar(data);
|
||||
}
|
||||
|
||||
this._store.add(contact);
|
||||
} catch (e) {
|
||||
debug(e, `Failed to parse VCard contact ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vCard using libebook and add it to the contact store.
|
||||
*
|
||||
* @param {string} uid - The contact UID
|
||||
* @param {string} vcard_data - The raw vCard data
|
||||
*/
|
||||
async _parseVCard(uid, vcard_data) {
|
||||
try {
|
||||
const contact = {
|
||||
id: uid,
|
||||
name: _('Unknown Contact'),
|
||||
numbers: [],
|
||||
origin: 'device',
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const evcard = EBookContacts.VCard.new_from_string(vcard_data);
|
||||
const attrs = evcard.get_attributes();
|
||||
|
||||
for (let i = 0, len = attrs.length; i < len; i++) {
|
||||
const attr = attrs[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;
|
||||
}
|
||||
}
|
||||
|
||||
this._store.add(contact);
|
||||
} catch (e) {
|
||||
debug(e, `Failed to parse VCard contact ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming list of contact vCards and pass them to the best
|
||||
* available parser.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards`
|
||||
*/
|
||||
_handleVCards(packet) {
|
||||
try {
|
||||
// We don't use this
|
||||
delete packet.body.uids;
|
||||
|
||||
// Parse each vCard and add the contact
|
||||
for (const [uid, vcard] of Object.entries(packet.body)) {
|
||||
if (EBookContacts)
|
||||
this._parseVCard(uid, vcard);
|
||||
else
|
||||
this._parseVCardNative(uid, vcard);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of contact UIDs with timestamps.
|
||||
*/
|
||||
_requestUids() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.contacts.request_all_uids_timestamps',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the vCards for @uids.
|
||||
*
|
||||
* @param {string[]} uids - A list of contact UIDs
|
||||
*/
|
||||
_requestVCards(uids) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.contacts.request_vcards_by_uid',
|
||||
body: {
|
||||
uids: uids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._store.disconnect(this._contactsStoreReadyId);
|
||||
this.settings.disconnect(this._contactsSourceChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Find My Phone'),
|
||||
description: _('Ring your paired device'),
|
||||
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
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectFindMyPhonePlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'findmyphone');
|
||||
|
||||
this._dialog = null;
|
||||
this._player = Components.acquire('sound');
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.findmyphone.request':
|
||||
this._handleRequest();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming location request.
|
||||
*/
|
||||
_handleRequest() {
|
||||
try {
|
||||
// If this is a second request, stop announcing and return
|
||||
if (this._dialog !== null) {
|
||||
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
return;
|
||||
}
|
||||
|
||||
this._dialog = new Dialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
|
||||
this._dialog.connect('response', () => {
|
||||
this._dialog = null;
|
||||
});
|
||||
} catch (e) {
|
||||
this._cancelRequest();
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing ringing and destroy the dialog.
|
||||
*/
|
||||
_cancelRequest() {
|
||||
if (this._dialog !== null)
|
||||
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request that the remote device announce it's location
|
||||
*/
|
||||
ring() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.findmyphone.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._cancelRequest();
|
||||
|
||||
if (this._mixer !== undefined)
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
|
||||
if (this._player !== undefined)
|
||||
this._player = Components.release('sound');
|
||||
|
||||
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',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'plugin': GObject.ParamSpec.object(
|
||||
'plugin',
|
||||
'Plugin',
|
||||
'The plugin providing messages',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
},
|
||||
}, class Dialog extends Gtk.MessageDialog {
|
||||
_init(params) {
|
||||
super._init({
|
||||
buttons: Gtk.ButtonsType.CLOSE,
|
||||
device: params.device,
|
||||
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,
|
||||
}),
|
||||
plugin: params.plugin,
|
||||
urgency_hint: true,
|
||||
});
|
||||
|
||||
this.set_keep_above(true);
|
||||
this.maximize();
|
||||
this.message_area.destroy();
|
||||
|
||||
// If an output stream is available start fading the volume up
|
||||
if (this.plugin._mixer && this.plugin._mixer.output) {
|
||||
this._stream = this.plugin._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
|
||||
if (this.plugin._player !== undefined)
|
||||
this.plugin._player.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;
|
||||
}
|
||||
|
||||
get device() {
|
||||
if (this._device === undefined)
|
||||
this._device = null;
|
||||
|
||||
return this._device;
|
||||
}
|
||||
|
||||
set device(device) {
|
||||
this._device = device;
|
||||
}
|
||||
|
||||
get plugin() {
|
||||
if (this._plugin === undefined)
|
||||
this._plugin = null;
|
||||
|
||||
return this._plugin;
|
||||
}
|
||||
|
||||
set plugin(plugin) {
|
||||
this._plugin = plugin;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const {InputDialog} = imports.service.ui.mousepad;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Mousepad'),
|
||||
description: _('Enables the paired device to act as a remote mouse and keyboard'),
|
||||
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
|
||||
),
|
||||
},
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'mousepad');
|
||||
|
||||
this._input = Components.acquire('input');
|
||||
|
||||
this._shareControlChangedId = this.settings.connect(
|
||||
'changed::share-control',
|
||||
this._sendState.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get state() {
|
||||
if (this._state === undefined)
|
||||
this._state = false;
|
||||
|
||||
return this._state;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
super.disconnected();
|
||||
|
||||
this._state = false;
|
||||
this.notify('state');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.mousepad.request':
|
||||
this._handleInput(packet.body);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mousepad.echo':
|
||||
this._handleEcho(packet.body);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mousepad.keyboardstate':
|
||||
this._handleState(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a input event.
|
||||
*
|
||||
* @param {Object} input - The body of a `kdeconnect.mousepad.request`
|
||||
*/
|
||||
_handleInput(input) {
|
||||
if (!this.settings.get_boolean('share-control'))
|
||||
return;
|
||||
|
||||
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)
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an echo/ACK of a event we sent, displaying it the dialog entry.
|
||||
*
|
||||
* @param {Object} input - The body of a `kdeconnect.mousepad.echo`
|
||||
*/
|
||||
_handleEcho(input) {
|
||||
if (!this._dialog || !this._dialog.visible)
|
||||
return;
|
||||
|
||||
// Skip modifiers
|
||||
if (input.alt || input.ctrl || input.super)
|
||||
return;
|
||||
|
||||
if (input.key) {
|
||||
this._dialog._isAck = true;
|
||||
this._dialog.text.buffer.text += input.key;
|
||||
this._dialog._isAck = false;
|
||||
} else if (KeyMap.get(input.specialKey) === Gdk.KEY_BackSpace) {
|
||||
this._dialog.text.emit('backspace');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a state change from the remote keyboard. This is an indication
|
||||
* that the remote keyboard is ready to accept input.
|
||||
*
|
||||
* @param {Object} packet - A `kdeconnect.mousepad.keyboardstate` packet
|
||||
*/
|
||||
_handleState(packet) {
|
||||
this._state = !!packet.body.state;
|
||||
this.notify('state');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an echo/ACK of @input, if requested
|
||||
*
|
||||
* @param {Object} input - The body of a 'kdeconnect.mousepad.request'
|
||||
*/
|
||||
_sendEcho(input) {
|
||||
if (!input.sendAck)
|
||||
return;
|
||||
|
||||
delete input.sendAck;
|
||||
input.isAck = true;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mousepad.echo',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.settings.get_boolean('share-control'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Keyboard Input dialog
|
||||
*/
|
||||
keyboard() {
|
||||
if (this._dialog === undefined) {
|
||||
this._dialog = new InputDialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
}
|
||||
|
||||
this._dialog.present();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._input !== undefined)
|
||||
this._input = Components.release('input');
|
||||
|
||||
if (this._dialog !== undefined)
|
||||
this._dialog.destroy();
|
||||
|
||||
this.settings.disconnect(this._shareControlChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,902 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const DBus = imports.service.utils.dbus;
|
||||
const MPRIS = imports.service.components.mpris;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('MPRIS'),
|
||||
description: _('Bidirectional remote media playback control'),
|
||||
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
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMPRISPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'mpris');
|
||||
|
||||
this._players = new Map();
|
||||
this._transferring = new WeakSet();
|
||||
this._updating = new WeakSet();
|
||||
|
||||
this._mpris = Components.acquire('mpris');
|
||||
|
||||
this._playerAddedId = this._mpris.connect(
|
||||
'player-added',
|
||||
this._sendPlayerList.bind(this)
|
||||
);
|
||||
|
||||
this._playerRemovedId = this._mpris.connect(
|
||||
'player-removed',
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestPlayerList();
|
||||
this._sendPlayerList();
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
super.disconnected();
|
||||
|
||||
for (const [identity, player] of this._players) {
|
||||
this._players.delete(identity);
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.mpris':
|
||||
this._handleUpdate(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mpris.request':
|
||||
this._handleRequest(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote player update.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris`
|
||||
*/
|
||||
_handleUpdate(packet) {
|
||||
try {
|
||||
if (packet.body.hasOwnProperty('playerList'))
|
||||
this._handlePlayerList(packet.body.playerList);
|
||||
else if (packet.body.hasOwnProperty('player'))
|
||||
this._handlePlayerUpdate(packet);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an updated list of remote players.
|
||||
*
|
||||
* @param {string[]} playerList - A list of remote player names
|
||||
*/
|
||||
_handlePlayerList(playerList) {
|
||||
// Destroy removed players before adding new ones
|
||||
for (const player of this._players.values()) {
|
||||
if (!playerList.includes(player.Identity)) {
|
||||
this._players.delete(player.Identity);
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
for (const identity of playerList) {
|
||||
if (!this._players.has(identity)) {
|
||||
const player = new PlayerRemote(this.device, identity);
|
||||
this._players.set(identity, player);
|
||||
}
|
||||
|
||||
// Always request player updates; packets are cheap
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: identity,
|
||||
requestNowPlaying: true,
|
||||
requestVolume: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an update for a remote player.
|
||||
*
|
||||
* @param {Object} packet - A `kdeconnect.mpris` packet
|
||||
*/
|
||||
_handlePlayerUpdate(packet) {
|
||||
const player = this._players.get(packet.body.player);
|
||||
|
||||
if (player === undefined)
|
||||
return;
|
||||
|
||||
if (packet.body.hasOwnProperty('transferringAlbumArt'))
|
||||
player.handleAlbumArt(packet);
|
||||
else
|
||||
player.update(packet.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of remote players.
|
||||
*/
|
||||
_requestPlayerList() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
requestPlayerList: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request for player information or action.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.mpris.request`
|
||||
* @return {undefined} no return value
|
||||
*/
|
||||
_handleRequest(packet) {
|
||||
// A request for the list of players
|
||||
if (packet.body.hasOwnProperty('requestPlayerList'))
|
||||
return this._sendPlayerList();
|
||||
|
||||
// A request for an unknown player; send the list of players
|
||||
if (!this._mpris.hasPlayer(packet.body.player))
|
||||
return this._sendPlayerList();
|
||||
|
||||
// An album art request
|
||||
if (packet.body.hasOwnProperty('albumArtUrl'))
|
||||
return this._sendAlbumArt(packet);
|
||||
|
||||
// A player command
|
||||
this._handleCommand(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming player command or information request
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris.request`
|
||||
*/
|
||||
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;
|
||||
|
||||
this._updating.add(player);
|
||||
|
||||
// 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:
|
||||
debug(`unknown action: ${packet.body.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Player Properties
|
||||
if (packet.body.hasOwnProperty('setLoopStatus'))
|
||||
player.LoopStatus = packet.body.setLoopStatus;
|
||||
|
||||
if (packet.body.hasOwnProperty('setShuffle'))
|
||||
player.Shuffle = packet.body.setShuffle;
|
||||
|
||||
if (packet.body.hasOwnProperty('setVolume'))
|
||||
player.Volume = packet.body.setVolume / 100;
|
||||
|
||||
if (packet.body.hasOwnProperty('Seek'))
|
||||
await player.Seek(packet.body.Seek * 1000);
|
||||
|
||||
if (packet.body.hasOwnProperty('SetPosition')) {
|
||||
const offset = (packet.body.SetPosition * 1000) - player.Position;
|
||||
await player.Seek(offset);
|
||||
}
|
||||
|
||||
// Information Request
|
||||
let hasResponse = false;
|
||||
|
||||
const 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,
|
||||
loopStatus: player.LoopStatus,
|
||||
shuffle: player.Shuffle,
|
||||
|
||||
// default values for members that will be filled conditionally
|
||||
albumArtUrl: '',
|
||||
length: 0,
|
||||
artist: '',
|
||||
title: '',
|
||||
album: '',
|
||||
nowPlaying: '',
|
||||
volume: 0,
|
||||
});
|
||||
|
||||
const metadata = player.Metadata;
|
||||
|
||||
if (metadata.hasOwnProperty('mpris:artUrl')) {
|
||||
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
|
||||
response.body.albumArtUrl = file.get_uri();
|
||||
}
|
||||
|
||||
if (metadata.hasOwnProperty('mpris:length')) {
|
||||
const trackLen = Math.floor(metadata['mpris:length'] / 1000);
|
||||
response.body.length = trackLen;
|
||||
}
|
||||
|
||||
if (metadata.hasOwnProperty('xesam:artist')) {
|
||||
const 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'];
|
||||
|
||||
// Now Playing
|
||||
if (response.body.artist && response.body.title) {
|
||||
response.body.nowPlaying = [
|
||||
response.body.artist,
|
||||
response.body.title,
|
||||
].join(' - ');
|
||||
} else if (response.body.artist) {
|
||||
response.body.nowPlaying = response.body.artist;
|
||||
} else if (response.body.title) {
|
||||
response.body.nowPlaying = response.body.title;
|
||||
} else {
|
||||
response.body.nowPlaying = _('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.body.hasOwnProperty('requestVolume')) {
|
||||
hasResponse = true;
|
||||
response.body.volume = Math.floor(player.Volume * 100);
|
||||
}
|
||||
|
||||
if (hasResponse)
|
||||
this.device.sendPacket(response);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
} 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, offset) {
|
||||
// TODO: although we can handle full seeked signals, kdeconnect-android
|
||||
// does not, and expects a position update instead
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
player: player.Identity,
|
||||
pos: Math.floor(player.Position / 1000),
|
||||
// Seek: Math.floor(offset / 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
|
||||
const metadata = player.Metadata;
|
||||
|
||||
if (!metadata.hasOwnProperty('mpris:artUrl'))
|
||||
return;
|
||||
|
||||
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
|
||||
const request = Gio.File.new_for_uri(packet.body.albumArtUrl);
|
||||
|
||||
if (file.get_uri() !== request.get_uri())
|
||||
throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);
|
||||
|
||||
// Transfer the album art
|
||||
this._transferring.add(player);
|
||||
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
transferringAlbumArt: true,
|
||||
player: packet.body.player,
|
||||
albumArtUrl: packet.body.albumArtUrl,
|
||||
},
|
||||
}, file);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
} 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.getIdentities();
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
playerList: playerList,
|
||||
supportAlbumArtPayload: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mpris !== undefined) {
|
||||
this._mpris.disconnect(this._playerAddedId);
|
||||
this._mpris.disconnect(this._playerRemovedId);
|
||||
this._mpris.disconnect(this._playerChangedId);
|
||||
this._mpris.disconnect(this._playerSeekedId);
|
||||
this._mpris = Components.release('mpris');
|
||||
}
|
||||
|
||||
for (const [identity, player] of this._players) {
|
||||
this._players.delete(identity);
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
* A class for mirroring a remote Media Player on DBus
|
||||
*/
|
||||
const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2');
|
||||
const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player');
|
||||
|
||||
|
||||
const PlayerRemote = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMPRISPlayerRemote',
|
||||
}, class PlayerRemote extends MPRIS.Player {
|
||||
|
||||
_init(device, identity) {
|
||||
super._init();
|
||||
|
||||
this._device = device;
|
||||
this._Identity = identity;
|
||||
this._isPlaying = false;
|
||||
|
||||
this._artist = null;
|
||||
this._title = null;
|
||||
this._album = null;
|
||||
this._length = 0;
|
||||
this._artUrl = null;
|
||||
|
||||
this._ownerId = 0;
|
||||
this._connection = null;
|
||||
this._applicationIface = null;
|
||||
this._playerIface = null;
|
||||
}
|
||||
|
||||
_getFile(albumArtUrl) {
|
||||
const hash = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5,
|
||||
albumArtUrl, -1);
|
||||
const path = GLib.build_filenamev([Config.CACHEDIR, hash]);
|
||||
|
||||
return Gio.File.new_for_uri(`file://${path}`);
|
||||
}
|
||||
|
||||
_requestAlbumArt(state) {
|
||||
if (this._artUrl === state.albumArtUrl)
|
||||
return;
|
||||
|
||||
const file = this._getFile(state.albumArtUrl);
|
||||
|
||||
if (file.query_exists(null)) {
|
||||
this._artUrl = file.get_uri();
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
} else {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
albumArtUrl: state.albumArtUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateMetadata(state) {
|
||||
let metadataChanged = false;
|
||||
|
||||
if (state.hasOwnProperty('artist')) {
|
||||
if (this._artist !== state.artist) {
|
||||
this._artist = state.artist;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._artist) {
|
||||
this._artist = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('title')) {
|
||||
if (this._title !== state.title) {
|
||||
this._title = state.title;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._title) {
|
||||
this._title = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('album')) {
|
||||
if (this._album !== state.album) {
|
||||
this._album = state.album;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._album) {
|
||||
this._album = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('length')) {
|
||||
if (this._length !== state.length * 1000) {
|
||||
this._length = state.length * 1000;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._length) {
|
||||
this._length = 0;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('albumArtUrl')) {
|
||||
this._requestAlbumArt(state);
|
||||
} else if (this._artUrl) {
|
||||
this._artUrl = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (metadataChanged) {
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async export() {
|
||||
try {
|
||||
if (this._connection === null) {
|
||||
this._connection = await DBus.newConnection();
|
||||
|
||||
if (this._applicationIface === null) {
|
||||
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 === null) {
|
||||
this._playerIface = new DBus.Interface({
|
||||
g_instance: this,
|
||||
g_connection: this._connection,
|
||||
g_object_path: '/org/mpris/MediaPlayer2',
|
||||
g_interface_info: MPRISPlayerIface,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this._ownerId !== 0)
|
||||
return;
|
||||
|
||||
const 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) {
|
||||
debug(e, this.Identity);
|
||||
}
|
||||
}
|
||||
|
||||
unexport() {
|
||||
if (this._ownerId === 0)
|
||||
return;
|
||||
|
||||
Gio.bus_unown_name(this._ownerId);
|
||||
this._ownerId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download album art for the current track of the remote player.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris` packet
|
||||
*/
|
||||
async handleAlbumArt(packet) {
|
||||
let file;
|
||||
|
||||
try {
|
||||
file = this._getFile(packet.body.albumArtUrl);
|
||||
|
||||
// Transfer the album art
|
||||
const transfer = this.device.createTransfer();
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
await transfer.start();
|
||||
|
||||
this._artUrl = file.get_uri();
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (file)
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the internal state of the media player.
|
||||
*
|
||||
* @param {Core.Packet} state - The body of a `kdeconnect.mpris` packet
|
||||
*/
|
||||
update(state) {
|
||||
this.freeze_notify();
|
||||
|
||||
// Metadata
|
||||
if (state.hasOwnProperty('nowPlaying'))
|
||||
this._updateMetadata(state);
|
||||
|
||||
// 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('pos'))
|
||||
this._Position = state.pos * 1000;
|
||||
|
||||
if (state.hasOwnProperty('volume')) {
|
||||
if (this.Volume !== state.volume / 100) {
|
||||
this._Volume = state.volume / 100;
|
||||
this.notify('Volume');
|
||||
}
|
||||
}
|
||||
|
||||
this.thaw_notify();
|
||||
|
||||
if (!this._isPlaying && !this.CanControl)
|
||||
this.unexport();
|
||||
else
|
||||
this.export();
|
||||
}
|
||||
|
||||
/*
|
||||
* Native properties
|
||||
*/
|
||||
get device() {
|
||||
return this._device;
|
||||
}
|
||||
|
||||
/*
|
||||
* The org.mpris.MediaPlayer2.Player Interface
|
||||
*/
|
||||
get CanControl() {
|
||||
return (this.CanPlay || this.CanPause);
|
||||
}
|
||||
|
||||
get Metadata() {
|
||||
if (this._Metadata === undefined) {
|
||||
this._Metadata = {};
|
||||
|
||||
if (this._artist) {
|
||||
this._Metadata['xesam:artist'] = new GLib.Variant('as',
|
||||
[this._artist]);
|
||||
}
|
||||
|
||||
if (this._title) {
|
||||
this._Metadata['xesam:title'] = new GLib.Variant('s',
|
||||
this._title);
|
||||
}
|
||||
|
||||
if (this._album) {
|
||||
this._Metadata['xesam:album'] = new GLib.Variant('s',
|
||||
this._album);
|
||||
}
|
||||
|
||||
if (this._artUrl) {
|
||||
this._Metadata['mpris:artUrl'] = new GLib.Variant('s',
|
||||
this._artUrl);
|
||||
}
|
||||
|
||||
this._Metadata['mpris:length'] = new GLib.Variant('x',
|
||||
this._length);
|
||||
}
|
||||
|
||||
return this._Metadata;
|
||||
}
|
||||
|
||||
get PlaybackStatus() {
|
||||
if (this._isPlaying)
|
||||
return 'Playing';
|
||||
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
get Volume() {
|
||||
if (this._Volume === undefined)
|
||||
this._Volume = 0.3;
|
||||
|
||||
return this._Volume;
|
||||
}
|
||||
|
||||
set Volume(level) {
|
||||
if (this._Volume === level)
|
||||
return;
|
||||
|
||||
this._Volume = level;
|
||||
this.notify('Volume');
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
setVolume: Math.floor(this._Volume * 100),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Next() {
|
||||
if (!this.CanGoNext)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Next',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Pause() {
|
||||
if (!this.CanPause)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Pause',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Play() {
|
||||
if (!this.CanPlay)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Play',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
PlayPause() {
|
||||
if (!this.CanPlay && !this.CanPause)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'PlayPause',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Previous() {
|
||||
if (!this.CanGoPrevious)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Previous',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Seek(offset) {
|
||||
if (!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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Stop() {
|
||||
if (!this.CanControl)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Stop',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unexport();
|
||||
|
||||
if (this._connection) {
|
||||
this._connection.close(null, null);
|
||||
this._connection = null;
|
||||
|
||||
if (this._applicationIface) {
|
||||
this._applicationIface.destroy();
|
||||
this._applicationIface = null;
|
||||
}
|
||||
|
||||
if (this._playerIface) {
|
||||
this._playerIface.destroy();
|
||||
this._playerIface = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,713 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
const NotificationUI = imports.service.ui.notification;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Notifications'),
|
||||
description: _('Share notifications with the paired device'),
|
||||
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
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Try to determine if an notification is from an SMS app
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @return {boolean} Whether the notification is from an SMS app
|
||||
*/
|
||||
function _isSmsNotification(packet) {
|
||||
const id = packet.body.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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a local libnotify or Gtk notification.
|
||||
*
|
||||
* @param {String|Number} id - Gtk (string) or libnotify id (uint32)
|
||||
* @param {String|null} application - Application Id if Gtk or null
|
||||
*/
|
||||
function _removeNotification(id, application = null) {
|
||||
let name, path, method, variant;
|
||||
|
||||
if (application !== null) {
|
||||
name = 'org.gtk.Notifications';
|
||||
method = 'RemoveNotification';
|
||||
path = '/org/gtk/Notifications';
|
||||
variant = new GLib.Variant('(ss)', [application, id]);
|
||||
} else {
|
||||
name = 'org.freedesktop.Notifications';
|
||||
path = '/org/freedesktop/Notifications';
|
||||
method = 'CloseNotification';
|
||||
variant = new GLib.Variant('(u)', [id]);
|
||||
}
|
||||
|
||||
Gio.DBus.session.call(
|
||||
name, path, name, method, variant, null,
|
||||
Gio.DBusCallFlags.NONE, -1, null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
connection.call_finish(res);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'notification');
|
||||
|
||||
this._listener = Components.acquire('notification');
|
||||
this._session = Components.acquire('session');
|
||||
|
||||
this._notificationAddedId = this._listener.connect(
|
||||
'notification-added',
|
||||
this._onNotificationAdded.bind(this)
|
||||
);
|
||||
|
||||
// Load application notification settings
|
||||
this._applicationsChangedId = this.settings.connect(
|
||||
'changed::applications',
|
||||
this._onApplicationsChanged.bind(this)
|
||||
);
|
||||
this._onApplicationsChanged(this.settings, 'applications');
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestNotifications();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.notification':
|
||||
this._handleNotification(packet);
|
||||
break;
|
||||
|
||||
// TODO
|
||||
case 'kdeconnect.notification.action':
|
||||
this._handleNotificationAction(packet);
|
||||
break;
|
||||
|
||||
// No Linux/BSD desktop notifications are repliable as yet
|
||||
case 'kdeconnect.notification.reply':
|
||||
debug(`Not implemented: ${packet.type}`);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.notification.request':
|
||||
this._handleNotificationRequest(packet);
|
||||
break;
|
||||
|
||||
default:
|
||||
debug(`Unknown notification packet: ${packet.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
_onApplicationsChanged(settings, key) {
|
||||
if (this._applicationsChangedSkip)
|
||||
return;
|
||||
|
||||
try {
|
||||
const json = settings.get_string(key);
|
||||
this._applications = JSON.parse(json);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
this._applicationsChangedSkip = true;
|
||||
settings.set_string(key, '{}');
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
}
|
||||
|
||||
_onNotificationAdded(listener, notification) {
|
||||
try {
|
||||
const notif = notification.full_unpack();
|
||||
|
||||
// An unconfigured application
|
||||
if (notif.appName && !this._applications[notif.appName]) {
|
||||
this._applications[notif.appName] = {
|
||||
iconName: 'system-run-symbolic',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Store the themed icons for the device preferences window
|
||||
if (notif.icon === undefined) {
|
||||
// Keep default
|
||||
|
||||
} else if (typeof notif.icon === 'string') {
|
||||
this._applications[notif.appName].iconName = notif.icon;
|
||||
|
||||
} else if (notif.icon instanceof Gio.ThemedIcon) {
|
||||
const iconName = notif.icon.get_names()[0];
|
||||
this._applications[notif.appName].iconName = iconName;
|
||||
}
|
||||
|
||||
this._applicationsChangedSkip = true;
|
||||
this.settings.set_string(
|
||||
'applications',
|
||||
JSON.stringify(this._applications)
|
||||
);
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
|
||||
// Sending notifications forbidden
|
||||
if (!this.settings.get_boolean('send-notifications'))
|
||||
return;
|
||||
|
||||
// Sending when the session is active is forbidden
|
||||
if (!this.settings.get_boolean('send-active') && this._session.active)
|
||||
return;
|
||||
|
||||
// Notifications disabled for this application
|
||||
if (notif.appName && !this._applications[notif.appName].enabled)
|
||||
return;
|
||||
|
||||
this.sendNotification(notif);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
*/
|
||||
_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 activate a notification action.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification.action`
|
||||
*/
|
||||
_handleNotificationAction(packet) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request to close or list notifications.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification.request`
|
||||
*/
|
||||
_handleNotificationRequest(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.
|
||||
if (packet.body.hasOwnProperty('cancel')) {
|
||||
const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
|
||||
|
||||
if (type === 'fdo')
|
||||
_removeNotification(parseInt(id));
|
||||
else if (type === 'gtk')
|
||||
_removeNotification(id, application);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a GLib.Bytes object.
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet for the notification
|
||||
* @param {GLib.Bytes} bytes - The icon bytes
|
||||
*/
|
||||
_uploadBytesIcon(packet, bytes) {
|
||||
const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
|
||||
this._uploadIconStream(packet, stream, bytes.get_size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a Gio.File object.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @param {Gio.File} file - A file object for the icon
|
||||
*/
|
||||
async _uploadFileIcon(packet, file) {
|
||||
const read = new Promise((resolve, reject) => {
|
||||
file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
|
||||
try {
|
||||
resolve(file.read_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const query = new Promise((resolve, reject) => {
|
||||
file.query_info_async(
|
||||
'standard::size',
|
||||
Gio.FileQueryInfoFlags.NONE,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.query_info_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const [stream, info] = await Promise.all([read, query]);
|
||||
|
||||
this._uploadIconStream(packet, stream, info.get_size());
|
||||
}
|
||||
|
||||
/**
|
||||
* A function for uploading GThemedIcons
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet for the notification
|
||||
* @param {Gio.ThemedIcon} icon - The GIcon to upload
|
||||
*/
|
||||
_uploadThemedIcon(packet, icon) {
|
||||
const theme = Gtk.IconTheme.get_default();
|
||||
let file = null;
|
||||
|
||||
for (const name of icon.names) {
|
||||
// NOTE: kdeconnect-android doesn't support SVGs
|
||||
const size = Math.max.apply(null, theme.get_icon_sizes(name));
|
||||
const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);
|
||||
|
||||
// Send the first icon we find from the options
|
||||
if (info) {
|
||||
file = Gio.File.new_for_path(info.get_filename());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (file)
|
||||
this._uploadFileIcon(packet, file);
|
||||
else
|
||||
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 {
|
||||
const transfer = this.device.createTransfer();
|
||||
transfer.addStream(packet, stream, size);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
|
||||
this.device.sendPacket(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a GIcon or themed icon name.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @param {Gio.Icon|string|null} icon - An icon or %null
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_uploadIcon(packet, icon = null) {
|
||||
// Normalize strings into GIcons
|
||||
if (typeof icon === 'string')
|
||||
icon = Gio.Icon.new_for_string(icon);
|
||||
|
||||
if (icon instanceof Gio.ThemedIcon)
|
||||
return this._uploadThemedIcon(packet, icon);
|
||||
|
||||
if (icon instanceof Gio.FileIcon)
|
||||
return this._uploadFileIcon(packet, icon.get_file());
|
||||
|
||||
if (icon instanceof Gio.BytesIcon)
|
||||
return this._uploadBytesIcon(packet, icon.get_bytes());
|
||||
|
||||
return this.device.sendPacket(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a local notification to the remote device.
|
||||
*
|
||||
* @param {Object} notif - A dictionary of notification parameters
|
||||
* @param {string} notif.appName - The notifying application
|
||||
* @param {string} notif.id - The notification ID
|
||||
* @param {string} notif.title - The notification title
|
||||
* @param {string} notif.body - The notification body
|
||||
* @param {string} notif.ticker - The notification title & body
|
||||
* @param {boolean} notif.isClearable - If the notification can be closed
|
||||
* @param {string|Gio.Icon} notif.icon - An icon name or GIcon
|
||||
*/
|
||||
async sendNotification(notif) {
|
||||
try {
|
||||
const icon = notif.icon || null;
|
||||
delete notif.icon;
|
||||
|
||||
await this._uploadIcon({
|
||||
type: 'kdeconnect.notification',
|
||||
body: notif,
|
||||
}, icon);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async _downloadIcon(packet) {
|
||||
try {
|
||||
if (!packet.hasPayload())
|
||||
return null;
|
||||
|
||||
// Save the file in the global cache
|
||||
const path = GLib.build_filenamev([
|
||||
Config.CACHEDIR,
|
||||
packet.body.payloadHash || `${Date.now()}`,
|
||||
]);
|
||||
|
||||
// Check if we've already downloaded this icon
|
||||
// NOTE: if we reject the transfer kdeconnect-android will resend
|
||||
// the notification packet, which may cause problems wrt #789
|
||||
const file = Gio.File.new_for_path(path);
|
||||
|
||||
if (file.query_exists(null))
|
||||
return new Gio.FileIcon({file: file});
|
||||
|
||||
// Open the target path and create a transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
return new Gio.FileIcon({file: file});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive an incoming notification.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
*/
|
||||
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);
|
||||
|
||||
// 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,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// Notification Actions
|
||||
if (packet.body.actions) {
|
||||
buttons = packet.body.actions.map(action => {
|
||||
return {
|
||||
label: action,
|
||||
action: 'activateNotification',
|
||||
parameter: new GLib.Variant('(ss)', [id, action]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Special case for Missed Calls
|
||||
if (packet.body.id.includes('MissedCall')) {
|
||||
title = packet.body.title;
|
||||
body = packet.body.text;
|
||||
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});
|
||||
|
||||
// Special case for SMS notifications
|
||||
} else if (_isSmsNotification(packet)) {
|
||||
title = packet.body.title;
|
||||
body = packet.body.text;
|
||||
action = {
|
||||
name: 'replySms',
|
||||
parameter: new GLib.Variant('s', packet.body.title),
|
||||
};
|
||||
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
|
||||
|
||||
// Special case where 'appName' is the same as 'title'
|
||||
} else if (packet.body.appName === packet.body.title) {
|
||||
body = packet.body.text;
|
||||
}
|
||||
|
||||
// Use the device icon if we still don't have one
|
||||
if (icon === null)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote notifications be sent
|
||||
*/
|
||||
_requestNotifications() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification.request',
|
||||
body: {request: true},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 this happens for some reason, things will explode
|
||||
if (!uuid)
|
||||
throw Error('Missing UUID');
|
||||
|
||||
// If the message has no content, open a dialog for the user to add one
|
||||
if (!message) {
|
||||
const 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.settings.disconnect(this._applicationsChangedId);
|
||||
|
||||
if (this._listener !== undefined) {
|
||||
this._listener.disconnect(this._notificationAddedId);
|
||||
this._listener = Components.release('notification');
|
||||
}
|
||||
|
||||
if (this._session !== undefined)
|
||||
this._session = Components.release('session');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Photo'),
|
||||
description: _('Request the paired device to take a photo and transfer it to this PC'),
|
||||
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 PluginBase.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),
|
||||
});
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.photo':
|
||||
this._receivePhoto(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.photo.request':
|
||||
this._sendPhoto(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a directory set for storing files that exists.
|
||||
*
|
||||
* @return {string} An absolute directory path
|
||||
*/
|
||||
_ensureReceiveDirectory() {
|
||||
if (this._receiveDir !== undefined)
|
||||
return this._receiveDir;
|
||||
|
||||
// Ensure a directory is set
|
||||
this._receiveDir = this.settings.get_string('receive-directory');
|
||||
|
||||
if (this._receiveDir === '') {
|
||||
this._receiveDir = GLib.get_user_special_dir(
|
||||
GLib.UserDirectory.DIRECTORY_PICTURES
|
||||
);
|
||||
|
||||
// Fallback to ~/Pictures
|
||||
const homeDir = GLib.get_home_dir();
|
||||
|
||||
if (!this._receiveDir || this._receiveDir === homeDir) {
|
||||
this._receiveDir = GLib.build_filenamev([homeDir, 'Pictures']);
|
||||
this.settings.set_string('receive-directory', this._receiveDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!GLib.file_test(this._receiveDir, GLib.FileTest.IS_DIR))
|
||||
GLib.mkdir_with_parents(this._receiveDir, 448);
|
||||
|
||||
return this._receiveDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a GFile for @filename, while ensuring the directory exists and the
|
||||
* file is unique.
|
||||
*
|
||||
* @param {string} filename - A filename (eg. `image.jpg`)
|
||||
* @return {Gio.File} a file object
|
||||
*/
|
||||
_getFile(filename) {
|
||||
const dirpath = this._ensureReceiveDirectory();
|
||||
const basepath = GLib.build_filenamev([dirpath, filename]);
|
||||
let filepath = basepath;
|
||||
let copyNum = 0;
|
||||
|
||||
while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
|
||||
filepath = `${basepath} (${++copyNum})`;
|
||||
|
||||
return Gio.File.new_for_path(filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a photo taken by the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.photo`
|
||||
*/
|
||||
async _receivePhoto(packet) {
|
||||
let file, transfer;
|
||||
|
||||
try {
|
||||
// Remote device cancelled the photo operation
|
||||
if (packet.body.hasOwnProperty('cancel'))
|
||||
return;
|
||||
|
||||
// Open the target path and create a transfer
|
||||
file = this._getFile(packet.body.filename);
|
||||
|
||||
transfer = this.device.createTransfer();
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
// Open the photo if successful, delete on failure
|
||||
await transfer.start();
|
||||
|
||||
const uri = file.get_uri();
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (file)
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a photo using the Webcam and return the path.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.photo.request`
|
||||
* @return {Promise<string>} A file path
|
||||
*/
|
||||
_takePhoto(packet) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const time = GLib.DateTime.new_now_local().format('%T');
|
||||
const path = GLib.build_filenamev([GLib.get_tmp_dir(), `${time}.jpg`]);
|
||||
const proc = this._launcher.spawnv([
|
||||
Config.FFMPEG_PATH,
|
||||
'-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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo to the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.photo.request`
|
||||
*/
|
||||
async _sendPhoto(packet) {
|
||||
if (this.settings.get_boolean('share-camera'))
|
||||
return;
|
||||
|
||||
let file, transfer;
|
||||
|
||||
try {
|
||||
// Take a photo
|
||||
const path = await this._takePhoto();
|
||||
|
||||
if (path.startsWith('file://'))
|
||||
file = Gio.File.new_for_uri(path);
|
||||
else
|
||||
file = Gio.File.new_for_path(path);
|
||||
|
||||
// Create the transfer
|
||||
transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.photo',
|
||||
body: {
|
||||
filename: file.get_basename(),
|
||||
},
|
||||
}, file);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (transfer) {
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: _('Transfer Failed'),
|
||||
// TRANSLATORS: eg. Failed to send "photo.jpg" to Google Pixel
|
||||
body: _('Failed to send “%s” to %s').format(
|
||||
file.get_basename(),
|
||||
this.device.name
|
||||
),
|
||||
icon: new Gio.ThemedIcon({name: 'dialog-warning-symbolic'}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote device begin a photo operation.
|
||||
*/
|
||||
photo() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.photo.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Ping'),
|
||||
description: _('Send and receive pings'),
|
||||
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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'ping');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
// Notification
|
||||
const notif = {
|
||||
title: this.device.name,
|
||||
body: _('Ping'),
|
||||
icon: new Gio.ThemedIcon({name: `${this.device.icon_name}`}),
|
||||
};
|
||||
|
||||
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 = '') {
|
||||
const packet = {
|
||||
type: 'kdeconnect.ping',
|
||||
body: {},
|
||||
};
|
||||
|
||||
if (message.length)
|
||||
packet.body.message = message;
|
||||
|
||||
this.device.sendPacket(packet);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Presentation'),
|
||||
description: _('Use the paired device as a presenter'),
|
||||
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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'presenter');
|
||||
|
||||
this._input = Components.acquire('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.
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._input !== undefined)
|
||||
this._input = Components.release('input');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Run Commands'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.RunCommand',
|
||||
description: _('Run commands on your paired device or let the device run predefined commands on this PC'),
|
||||
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 PluginBase.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;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._sendCommandList();
|
||||
this._requestCommandList();
|
||||
this._handleCommandList(this.remote_commands);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._remote_commands = {};
|
||||
this.notify('remote-commands');
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
if (!this.device.connected)
|
||||
return;
|
||||
|
||||
this._sendCommandList();
|
||||
this._requestCommandList();
|
||||
this._handleCommandList(this.remote_commands);
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.runcommand':
|
||||
this._handleCommandList(packet.body.commandList);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.runcommand.request':
|
||||
if (packet.body.hasOwnProperty('key'))
|
||||
this._handleCommand(packet.body.key);
|
||||
|
||||
else if (packet.body.hasOwnProperty('requestCommandList'))
|
||||
this._sendCommandList();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request to execute the local command with the UUID @key
|
||||
*
|
||||
* @param {string} key - The UUID of the local command
|
||||
*/
|
||||
_handleCommand(key) {
|
||||
try {
|
||||
const commands = this.settings.get_value('command-list');
|
||||
const commandList = commands.recursiveUnpack();
|
||||
|
||||
if (!commandList.hasOwnProperty(key)) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.PERMISSION_DENIED,
|
||||
message: `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.
|
||||
*
|
||||
* @param {string|Object[]} commandList - A list of remote commands
|
||||
*/
|
||||
_handleCommandList(commandList) {
|
||||
// See: https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/1051
|
||||
if (typeof commandList === 'string') {
|
||||
try {
|
||||
commandList = JSON.parse(commandList);
|
||||
} catch (e) {
|
||||
commandList = {};
|
||||
}
|
||||
}
|
||||
|
||||
this._remote_commands = commandList;
|
||||
this.notify('remote-commands');
|
||||
|
||||
const 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
|
||||
const submenu = new Gio.Menu();
|
||||
|
||||
for (const [uuid, info] of commandEntries) {
|
||||
const 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
|
||||
const 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
|
||||
const menuActions = this.device.settings.get_strv('menu-actions');
|
||||
const index = menuActions.indexOf('commands');
|
||||
|
||||
if (index > -1) {
|
||||
this.device.removeMenuAction('commands');
|
||||
this.device.addMenuItem(item, index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const commands = this.settings.get_value('command-list').recursiveUnpack();
|
||||
const commandList = JSON.stringify(commands);
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.runcommand',
|
||||
body: {commandList: commandList},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.settings.disconnect(this._commandListChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const Lan = imports.service.backends.lan;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('SFTP'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
|
||||
description: _('Browse the paired device filesystem'),
|
||||
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'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const MAX_MOUNT_DIRS = 12;
|
||||
|
||||
|
||||
/**
|
||||
* 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({
|
||||
GTypeName: 'GSConnectSFTPPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'sftp');
|
||||
|
||||
this._gmount = null;
|
||||
this._mounting = false;
|
||||
|
||||
// A reusable launcher for ssh processes
|
||||
this._launcher = new Gio.SubprocessLauncher({
|
||||
flags: (Gio.SubprocessFlags.STDOUT_PIPE |
|
||||
Gio.SubprocessFlags.STDERR_MERGE),
|
||||
});
|
||||
|
||||
// Watch the volume monitor
|
||||
this._volumeMonitor = Gio.VolumeMonitor.get();
|
||||
|
||||
this._mountAddedId = this._volumeMonitor.connect(
|
||||
'mount-added',
|
||||
this._onMountAdded.bind(this)
|
||||
);
|
||||
|
||||
this._mountRemovedId = this._volumeMonitor.connect(
|
||||
'mount-removed',
|
||||
this._onMountRemoved.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get gmount() {
|
||||
if (this._gmount === null && this.device.connected) {
|
||||
const host = this.device.channel.host;
|
||||
|
||||
const regex = new RegExp(
|
||||
`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
|
||||
);
|
||||
|
||||
for (const mount of this._volumeMonitor.get_mounts()) {
|
||||
const uri = mount.get_root().get_uri();
|
||||
|
||||
if (regex.test(uri)) {
|
||||
this._gmount = mount;
|
||||
this._addSubmenu(mount);
|
||||
this._addSymlink(mount);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._gmount;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
// Only enable for Lan connections
|
||||
if (this.device.channel instanceof Lan.Channel) {
|
||||
if (this.settings.get_boolean('automount'))
|
||||
this.mount();
|
||||
} else {
|
||||
this.device.lookup_action('mount').enabled = false;
|
||||
this.device.lookup_action('unmount').enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.sftp':
|
||||
if (packet.body.hasOwnProperty('errorMessage'))
|
||||
this._handleError(packet);
|
||||
else
|
||||
this._handleMount(packet);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onMountAdded(monitor, mount) {
|
||||
if (this._gmount !== null || !this.device.connected)
|
||||
return;
|
||||
|
||||
const host = this.device.channel.host;
|
||||
const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
|
||||
const uri = mount.get_root().get_uri();
|
||||
|
||||
if (!regex.test(uri))
|
||||
return;
|
||||
|
||||
this._gmount = mount;
|
||||
this._addSubmenu(mount);
|
||||
this._addSymlink(mount);
|
||||
}
|
||||
|
||||
_onMountRemoved(monitor, mount) {
|
||||
if (this.gmount !== mount)
|
||||
return;
|
||||
|
||||
this._gmount = null;
|
||||
this._removeSubmenu();
|
||||
}
|
||||
|
||||
async _listDirectories(mount) {
|
||||
const file = mount.get_root();
|
||||
|
||||
const iter = await new Promise((resolve, reject) => {
|
||||
file.enumerate_children_async(
|
||||
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.enumerate_children_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const infos = await new Promise((resolve, reject) => {
|
||||
iter.next_files_async(
|
||||
MAX_MOUNT_DIRS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(iter, res) => {
|
||||
try {
|
||||
resolve(iter.next_files_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
|
||||
const directories = {};
|
||||
|
||||
for (const info of infos) {
|
||||
const name = info.get_name();
|
||||
directories[name] = `${file.get_uri()}${name}/`;
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
_onAskQuestion(op, message, choices) {
|
||||
op.reply(Gio.MountOperationResult.HANDLED);
|
||||
}
|
||||
|
||||
_onAskPassword(op, message, user, domain, flags) {
|
||||
op.reply(Gio.MountOperationResult.HANDLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error reported by the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
||||
*/
|
||||
_handleError(packet) {
|
||||
this.device.showNotification({
|
||||
id: 'sftp-error',
|
||||
title: _('%s reported an error').format(this.device.name),
|
||||
body: packet.body.errorMessage,
|
||||
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
|
||||
priority: Gio.NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the remote device using the provided information.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
||||
*/
|
||||
async _handleMount(packet) {
|
||||
try {
|
||||
// Already mounted or mounting
|
||||
if (this.gmount !== null || this._mounting)
|
||||
return;
|
||||
|
||||
this._mounting = true;
|
||||
|
||||
// Ensure the private key is in the keyring
|
||||
await this._addPrivateKey();
|
||||
|
||||
// Create a new mount operation
|
||||
const op = new Gio.MountOperation({
|
||||
username: packet.body.user || null,
|
||||
password: packet.body.password || null,
|
||||
password_save: Gio.PasswordSave.NEVER,
|
||||
});
|
||||
|
||||
op.connect('ask-question', this._onAskQuestion);
|
||||
op.connect('ask-password', this._onAskPassword);
|
||||
|
||||
// This is the actual call to mount the device
|
||||
const host = this.device.channel.host;
|
||||
const uri = `sftp://${host}:${packet.body.port}/`;
|
||||
const file = Gio.File.new_for_uri(uri);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
file.mount_enclosing_volume(0, op, null, (file, res) => {
|
||||
try {
|
||||
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.matches(Gio.IOErrorEnum, 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(host);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
} finally {
|
||||
this._mounting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add GSConnect's private key identity to the authentication agent so our
|
||||
* identity can be verified by Android during private key authentication.
|
||||
*
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_addPrivateKey() {
|
||||
const ssh_add = this._launcher.spawnv([
|
||||
Config.SSHADD_PATH,
|
||||
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
|
||||
]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssh_add.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const 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 {
|
||||
const ssh_keygen = this._launcher.spawnv([
|
||||
Config.SSHKEYGEN_PATH,
|
||||
'-R',
|
||||
`[${host}]:${port}`,
|
||||
]);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ssh_keygen.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const stdout = proc.communicate_utf8_finish(res)[1];
|
||||
const status = proc.get_exit_status();
|
||||
|
||||
if (status !== 0) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.io_error_from_errno(status),
|
||||
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Mount menu helpers
|
||||
*/
|
||||
_getUnmountSection() {
|
||||
if (this._unmountSection === undefined) {
|
||||
this._unmountSection = new Gio.Menu();
|
||||
|
||||
const 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;
|
||||
}
|
||||
|
||||
_getFilesMenuItem() {
|
||||
if (this._filesMenuItem === undefined) {
|
||||
// Files menu icon
|
||||
const emblem = new Gio.Emblem({
|
||||
icon: new Gio.ThemedIcon({name: 'emblem-default'}),
|
||||
});
|
||||
|
||||
const mountedIcon = new Gio.EmblemedIcon({
|
||||
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
|
||||
});
|
||||
mountedIcon.add_emblem(emblem);
|
||||
|
||||
// Files menu item
|
||||
this._filesMenuItem = new Gio.MenuItem();
|
||||
this._filesMenuItem.set_detailed_action('device.mount');
|
||||
this._filesMenuItem.set_icon(mountedIcon);
|
||||
this._filesMenuItem.set_label(_('Files'));
|
||||
}
|
||||
|
||||
return this._filesMenuItem;
|
||||
}
|
||||
|
||||
async _addSubmenu(mount) {
|
||||
try {
|
||||
const directories = await this._listDirectories(mount);
|
||||
|
||||
// Submenu sections
|
||||
const dirSection = new Gio.Menu();
|
||||
const unmountSection = this._getUnmountSection();
|
||||
|
||||
for (const [name, uri] of Object.entries(directories))
|
||||
dirSection.append(name, `device.openPath::${uri}`);
|
||||
|
||||
// Files submenu
|
||||
const filesSubmenu = new Gio.Menu();
|
||||
filesSubmenu.append_section(null, dirSection);
|
||||
filesSubmenu.append_section(null, unmountSection);
|
||||
|
||||
// Files menu item
|
||||
const filesMenuItem = this._getFilesMenuItem();
|
||||
filesMenuItem.set_submenu(filesSubmenu);
|
||||
|
||||
// Replace the existing menu item
|
||||
const index = this.device.removeMenuAction('device.mount');
|
||||
this.device.addMenuItem(filesMenuItem, index);
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
||||
debug(e, this.device.name);
|
||||
|
||||
// Reset to allow retrying
|
||||
this._gmount = null;
|
||||
}
|
||||
}
|
||||
|
||||
_removeSubmenu() {
|
||||
try {
|
||||
const index = this.device.removeMenuAction('device.mount');
|
||||
const 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, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a symbolic link referring to the device by name
|
||||
*
|
||||
* @param {Gio.Mount} mount - A GMount to link to
|
||||
*/
|
||||
async _addSymlink(mount) {
|
||||
try {
|
||||
const by_name_dir = Gio.File.new_for_path(
|
||||
`${Config.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 = '··';
|
||||
|
||||
const link_target = mount.get_root().get_path();
|
||||
const link = Gio.File.new_for_path(
|
||||
`${by_name_dir.get_path()}/${safe_device_name}`
|
||||
);
|
||||
|
||||
// Check for and remove any existing stale link
|
||||
try {
|
||||
const 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() {
|
||||
if (this.gmount !== null)
|
||||
return;
|
||||
|
||||
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.gmount === null)
|
||||
return;
|
||||
|
||||
this._removeSubmenu();
|
||||
this._mounting = false;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.gmount.unmount_with_operation(
|
||||
Gio.MountUnmountFlags.FORCE,
|
||||
new Gio.MountOperation(),
|
||||
null,
|
||||
(mount, res) => {
|
||||
try {
|
||||
resolve(mount.unmount_with_operation_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._volumeMonitor) {
|
||||
this._volumeMonitor.disconnect(this._mountAddedId);
|
||||
this._volumeMonitor.disconnect(this._mountRemovedId);
|
||||
this._volumeMonitor = null;
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
'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 PluginBase = imports.service.plugin;
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Share'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share',
|
||||
description: _('Share files and URLs between devices'),
|
||||
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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'share');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
// TODO: composite jobs (lastModified, numberOfFiles, totalPayloadSize)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureReceiveDirectory() {
|
||||
let receiveDir = this.settings.get_string('receive-directory');
|
||||
|
||||
// Ensure a directory is set
|
||||
if (receiveDir.length === 0) {
|
||||
receiveDir = GLib.get_user_special_dir(
|
||||
GLib.UserDirectory.DIRECTORY_DOWNLOAD
|
||||
);
|
||||
|
||||
// Fallback to ~/Downloads
|
||||
const 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) {
|
||||
const dirpath = this._ensureReceiveDirectory();
|
||||
const basepath = GLib.build_filenamev([dirpath, filename]);
|
||||
let filepath = basepath;
|
||||
let copyNum = 0;
|
||||
|
||||
while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
|
||||
filepath = `${basepath} (${++copyNum})`;
|
||||
|
||||
return Gio.File.new_for_path(filepath);
|
||||
}
|
||||
|
||||
_refuseFile(packet) {
|
||||
try {
|
||||
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) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleFile(packet) {
|
||||
try {
|
||||
const file = this._getFile(packet.body.filename);
|
||||
|
||||
// Create the transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
// 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'}),
|
||||
});
|
||||
|
||||
// We'll show a notification (success or failure)
|
||||
let title, body, iconName;
|
||||
let buttons = [];
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
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';
|
||||
|
||||
if (packet.body.open) {
|
||||
const uri = file.get_uri();
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
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_async(GLib.PRIORITY_DEAFAULT, null, null);
|
||||
}
|
||||
|
||||
this.device.hideNotification(transfer.uuid);
|
||||
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) {
|
||||
const uri = packet.body.url;
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
}
|
||||
|
||||
_handleText(packet) {
|
||||
const 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the file chooser dialog for selecting a file or inputing a URI.
|
||||
*/
|
||||
share() {
|
||||
const 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) {
|
||||
try {
|
||||
let file = null;
|
||||
|
||||
if (path.includes('://'))
|
||||
file = Gio.File.new_for_uri(path);
|
||||
else
|
||||
file = Gio.File.new_for_path(path);
|
||||
|
||||
// Create the transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.share.request',
|
||||
body: {
|
||||
filename: file.get_basename(),
|
||||
open: open,
|
||||
},
|
||||
}, file);
|
||||
|
||||
// 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'}),
|
||||
});
|
||||
|
||||
// We'll show a notification (success or failure)
|
||||
let title, body, iconName;
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
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';
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
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 - A URI to share
|
||||
*/
|
||||
shareUri(uri) {
|
||||
if (GLib.uri_parse_scheme(uri) === 'file') {
|
||||
this.shareFile(uri);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
const box = this.get_content_area().get_children()[0].get_children()[0];
|
||||
const 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);
|
||||
const 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 {
|
||||
const 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) {
|
||||
const 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) {
|
||||
for (const uri of this.get_uris()) {
|
||||
const parameter = new GLib.Variant(
|
||||
'(sb)',
|
||||
[uri, this.extra_widget.active]
|
||||
);
|
||||
this.device.activate_action('shareFile', parameter);
|
||||
}
|
||||
} else if (response_id === 1) {
|
||||
const parameter = new GLib.Variant('s', this._uriEntry.text);
|
||||
this.device.activate_action('shareUri', parameter);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
const LegacyMessaging = imports.service.ui.legacyMessaging;
|
||||
const Messaging = imports.service.ui.messaging;
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('SMS'),
|
||||
description: _('Send and read SMS of the paired device and be notified of new 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,
|
||||
QUEUED: 6,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'sms');
|
||||
|
||||
this.cacheProperties(['_threads']);
|
||||
}
|
||||
|
||||
get threads() {
|
||||
if (this._threads === undefined)
|
||||
this._threads = {};
|
||||
|
||||
return this._threads;
|
||||
}
|
||||
|
||||
get window() {
|
||||
if (this.settings.get_boolean('legacy-sms')) {
|
||||
return new LegacyMessaging.Dialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._window === undefined) {
|
||||
this._window = new Messaging.Window({
|
||||
application: Gio.Application.get_default(),
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
|
||||
this._window.connect('destroy', () => {
|
||||
this._window = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return this._window;
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._threads = {};
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
this._requestConversations();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.sms.messages':
|
||||
this._handleMessages(packet.body.messages);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (const 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++) {
|
||||
const message = messages[i];
|
||||
const cache = this.threads[message.thread_id];
|
||||
|
||||
if (cache === undefined) {
|
||||
this._requestConversation(message.thread_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this message is marked read, mark the rest as read
|
||||
if (message.read === MessageStatus.READ) {
|
||||
for (const msg of cache)
|
||||
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.length || cache[cache.length - 1].date < message.date)
|
||||
this._requestConversation(message.thread_id);
|
||||
}
|
||||
|
||||
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) {
|
||||
// If there are no addresses this will cause major problems...
|
||||
if (!thread[0].addresses || !thread[0].addresses[0])
|
||||
return;
|
||||
|
||||
const thread_id = thread[0].thread_id;
|
||||
const cache = this.threads[thread_id] || [];
|
||||
|
||||
// Handle each message
|
||||
for (let i = 0, len = thread.length; i < len; i++) {
|
||||
const message = thread[i];
|
||||
|
||||
// TODO: We only cache messages of a known MessageBox since we
|
||||
// have no reliable way to determine its direction, let alone
|
||||
// what to do with it.
|
||||
if (message.type < 0 || message.type > 6)
|
||||
continue;
|
||||
|
||||
// If the message exists, just update it
|
||||
const 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 notify
|
||||
this.threads[thread_id] = cache.sort((a, b) => a.date - b.date);
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const thread_ids = [];
|
||||
|
||||
// Perform some modification of the messages
|
||||
for (let i = 0, len = messages.length; i < len; i++) {
|
||||
const 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) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.device.sendPacket({
|
||||
type: 'kdeconnect.sms.request',
|
||||
body: {
|
||||
sendSms: true,
|
||||
phoneNumber: phoneNumber,
|
||||
messageBody: messageBody,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*
|
||||
* @param {Object[]} 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')) {
|
||||
const 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) {
|
||||
const window = new Messaging.ConversationChooser({
|
||||
application: Gio.Application.get_default(),
|
||||
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
|
||||
const addresses = uri.recipients.map(number => {
|
||||
return {address: number.toPhoneNumber()};
|
||||
});
|
||||
const contacts = this.device.contacts.lookupAddresses(addresses);
|
||||
|
||||
// Present the window and show the conversation
|
||||
const 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) {
|
||||
debug(e, `${this.device.name}: "${uri}"`);
|
||||
}
|
||||
}
|
||||
|
||||
_threadHasAddress(thread, addressObj) {
|
||||
const number = addressObj.address.toPhoneNumber();
|
||||
|
||||
for (const taddressObj of thread[0].addresses) {
|
||||
const 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 {Object[]} addresses - a list of address objects
|
||||
* @return {string|null} a thread ID
|
||||
*/
|
||||
getThreadIdForAddresses(addresses = []) {
|
||||
const threads = Object.values(this.threads);
|
||||
|
||||
for (const 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 !== undefined)
|
||||
this._window.destroy();
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('System Volume'),
|
||||
description: _('Enable the paired device to control the 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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'systemvolume');
|
||||
|
||||
// Cache stream properties
|
||||
this._cache = new WeakMap();
|
||||
|
||||
// Connect to the mixer
|
||||
try {
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
|
||||
this._streamChangedId = this._mixer.connect(
|
||||
'stream-changed',
|
||||
this._sendSink.bind(this)
|
||||
);
|
||||
|
||||
this._outputAddedId = this._mixer.connect(
|
||||
'output-added',
|
||||
this._sendSinkList.bind(this)
|
||||
);
|
||||
|
||||
this._outputRemovedId = this._mixer.connect(
|
||||
'output-removed',
|
||||
this._sendSinkList.bind(this)
|
||||
);
|
||||
|
||||
// Modify the error to redirect to the wiki
|
||||
} catch (e) {
|
||||
e.name = _('PulseAudio not found');
|
||||
e.url = `${Config.PACKAGE_URL}/wiki/Error#pulseaudio-not-found`;
|
||||
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
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.systemvolume.request`
|
||||
*/
|
||||
_changeSink(packet) {
|
||||
let stream;
|
||||
|
||||
for (const sink of this._mixer.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
|
||||
const 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) {
|
||||
const state = {
|
||||
name: stream.name,
|
||||
description: stream.display_name,
|
||||
muted: stream.is_muted,
|
||||
volume: stream.volume,
|
||||
maxVolume: this._mixer.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._mixer.fading)
|
||||
return;
|
||||
|
||||
// Check the cache
|
||||
const stream = this._mixer.lookup_stream_id(id);
|
||||
const 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
|
||||
const state = this._updateCache(stream);
|
||||
|
||||
// Send the stream update
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.systemvolume',
|
||||
body: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a list of local sinks
|
||||
*/
|
||||
_sendSinkList() {
|
||||
const sinkList = this._mixer.get_sinks().map(sink => {
|
||||
return this._updateCache(sink);
|
||||
});
|
||||
|
||||
// Send the sinkList
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.systemvolume',
|
||||
body: {
|
||||
sinkList: sinkList,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mixer !== undefined) {
|
||||
this._mixer.disconnect(this._streamChangedId);
|
||||
this._mixer.disconnect(this._outputAddedId);
|
||||
this._mixer.disconnect(this._outputRemovedId);
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const GdkPixbuf = imports.gi.GdkPixbuf;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Telephony'),
|
||||
description: _('Be notified about calls and adjust system volume during ringing/ongoing calls'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.telephony',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.telephony.request',
|
||||
'kdeconnect.telephony.request_mute',
|
||||
],
|
||||
actions: {
|
||||
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 PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'telephony');
|
||||
|
||||
// Neither of these are crucial for the plugin to work
|
||||
this._mpris = Components.acquire('mpris');
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.telephony':
|
||||
this._handleEvent(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (this._mixer !== undefined) {
|
||||
switch (this.settings.get_string(`${eventType}-volume`)) {
|
||||
case 'restore':
|
||||
this._mixer.restore();
|
||||
break;
|
||||
|
||||
case 'lower':
|
||||
this._mixer.lowerVolume();
|
||||
break;
|
||||
|
||||
case 'mute':
|
||||
this._mixer.muteVolume();
|
||||
break;
|
||||
}
|
||||
|
||||
if (eventType === 'talking' && this.settings.get_boolean('talking-microphone'))
|
||||
this._mixer.muteMicrophone();
|
||||
}
|
||||
|
||||
// Media Playback
|
||||
if (this._mpris && this.settings.get_boolean(`${eventType}-pause`))
|
||||
this._mpris.pauseAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore volume, microphone and media player state (if changed), making
|
||||
* sure to unpause before raising volume.
|
||||
*
|
||||
* TODO: there's a possibility we might revert a media/mixer state set for
|
||||
* another device.
|
||||
*/
|
||||
_restoreMediaState() {
|
||||
// Media Playback
|
||||
if (this._mpris)
|
||||
this._mpris.unpauseAll();
|
||||
|
||||
// Mixer Volume
|
||||
if (this._mixer)
|
||||
this._mixer.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a Gdk.Pixbuf from base64 encoded data
|
||||
*
|
||||
* @param {string} data - Base64 encoded JPEG data
|
||||
* @return {Gdk.Pixbuf|null} A contact photo
|
||||
*/
|
||||
_getThumbnailPixbuf(data) {
|
||||
const loader = new GdkPixbuf.PixbufLoader();
|
||||
|
||||
try {
|
||||
data = GLib.base64_decode(data);
|
||||
loader.write(data);
|
||||
loader.close();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
|
||||
return loader.get_pixbuf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a telephony event (ringing, talking), showing or hiding a
|
||||
* notification and possibly adjusting the media/mixer state.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.telephony`
|
||||
*/
|
||||
_handleEvent(packet) {
|
||||
// Only handle 'ringing' or 'talking' events; leave the notification
|
||||
// plugin to handle 'missedCall' since they're often repliable
|
||||
if (!['ringing', 'talking'].includes(packet.body.event))
|
||||
return;
|
||||
|
||||
// This is the end of a telephony event
|
||||
if (packet.body.isCancel)
|
||||
this._cancelEvent(packet);
|
||||
else
|
||||
this._notifyEvent(packet);
|
||||
}
|
||||
|
||||
_cancelEvent(packet) {
|
||||
// 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;
|
||||
|
||||
this.device.hideNotification(`${packet.body.event}|${sender}`);
|
||||
this._restoreMediaState();
|
||||
}
|
||||
|
||||
_notifyEvent(packet) {
|
||||
let body;
|
||||
let buttons = [];
|
||||
let icon = null;
|
||||
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 (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'call-start-symbolic'});
|
||||
|
||||
// Notify based based on the event type
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence an incoming call and restore the previous mixer/media state, if
|
||||
* applicable.
|
||||
*/
|
||||
muteCall() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.telephony.request_mute',
|
||||
body: {},
|
||||
});
|
||||
|
||||
this._restoreMediaState();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mixer !== undefined)
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
|
||||
if (this._mpris !== undefined)
|
||||
this._mpris = Components.release('mpris');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
|
||||
/*
|
||||
* Window State
|
||||
*/
|
||||
Gtk.Window.prototype.restoreGeometry = function (context = 'default') {
|
||||
this._windowState = new Gio.Settings({
|
||||
settings_schema: Config.GSCHEMA.lookup(
|
||||
'org.gnome.Shell.Extensions.GSConnect.WindowState',
|
||||
true
|
||||
),
|
||||
path: `/org/gnome/shell/extensions/gsconnect/${context}/`,
|
||||
});
|
||||
|
||||
// Size
|
||||
const [width, height] = this._windowState.get_value('window-size').deepUnpack();
|
||||
|
||||
if (width && height)
|
||||
this.set_default_size(width, height);
|
||||
|
||||
// Maximized State
|
||||
if (this._windowState.get_boolean('window-maximized'))
|
||||
this.maximize();
|
||||
};
|
||||
|
||||
Gtk.Window.prototype.saveGeometry = function () {
|
||||
const state = this.get_window().get_state();
|
||||
|
||||
// Maximized State
|
||||
const maximized = (state & Gdk.WindowState.MAXIMIZED);
|
||||
this._windowState.set_boolean('window-maximized', maximized);
|
||||
|
||||
// Leave the size at the value before maximizing
|
||||
if (maximized || (state & Gdk.WindowState.FULLSCREEN))
|
||||
return;
|
||||
|
||||
// Size
|
||||
const size = this.get_size();
|
||||
this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const GdkPixbuf = imports.gi.GdkPixbuf;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
|
||||
/**
|
||||
* Return a random color
|
||||
*
|
||||
* @param {*} [salt] - If not %null, will be used as salt for generating a color
|
||||
* @param {number} alpha - A value in the [0...1] range for the alpha channel
|
||||
* @return {Gdk.RGBA} A new Gdk.RGBA object generated from the input
|
||||
*/
|
||||
function randomRGBA(salt = null, alpha = 1.0) {
|
||||
let red, green, blue;
|
||||
|
||||
if (salt !== null) {
|
||||
const hash = new GLib.Variant('s', `${salt}`).hash();
|
||||
red = ((hash & 0xFF0000) >> 16) / 255;
|
||||
green = ((hash & 0x00FF00) >> 8) / 255;
|
||||
blue = (hash & 0x0000FF) / 255;
|
||||
} else {
|
||||
red = Math.random();
|
||||
green = Math.random();
|
||||
blue = Math.random();
|
||||
}
|
||||
|
||||
return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the relative luminance of a RGB set
|
||||
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
|
||||
*
|
||||
* @param {Gdk.RGBA} rgba - A GdkRGBA object
|
||||
* @return {number} The relative luminance of the color
|
||||
*/
|
||||
function relativeLuminance(rgba) {
|
||||
const {red, green, blue} = rgba;
|
||||
|
||||
const R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
|
||||
const G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
|
||||
const B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);
|
||||
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a GdkRGBA contrasted for the input
|
||||
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
|
||||
*
|
||||
* @param {Gdk.RGBA} rgba - A GdkRGBA object for the background color
|
||||
* @return {Gdk.RGBA} A GdkRGBA object for the foreground color
|
||||
*/
|
||||
function getFgRGBA(rgba) {
|
||||
const bgLuminance = relativeLuminance(rgba);
|
||||
const lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
|
||||
const darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
|
||||
|
||||
const value = (darkContrast > lightContrast) ? 0.06 : 0.94;
|
||||
return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a GdkPixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
|
||||
* sends. This function is synchronous.
|
||||
*
|
||||
* @param {string} path - A local file path
|
||||
* @param {number} size - Size in pixels
|
||||
* @param {scale} [scale] - Scale factor for the size
|
||||
* @return {Gdk.Pixbuf} A pixbuf
|
||||
*/
|
||||
function getPixbufForPath(path, size, scale = 1.0) {
|
||||
let data, loader;
|
||||
|
||||
// Catch missing avatar files
|
||||
try {
|
||||
data = GLib.file_get_contents(path)[1];
|
||||
} catch (e) {
|
||||
debug(e, path);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Consider errors from partially corrupt JPEGs to be warnings
|
||||
try {
|
||||
loader = new GdkPixbuf.PixbufLoader();
|
||||
loader.write(data);
|
||||
loader.close();
|
||||
} catch (e) {
|
||||
debug(e, path);
|
||||
}
|
||||
|
||||
const pixbuf = loader.get_pixbuf();
|
||||
|
||||
// Scale to monitor
|
||||
size = Math.floor(size * scale);
|
||||
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
|
||||
}
|
||||
|
||||
function getPixbufForIcon(name, size, scale, bgColor) {
|
||||
const color = getFgRGBA(bgColor);
|
||||
const theme = Gtk.IconTheme.get_default();
|
||||
const info = theme.lookup_icon_for_scale(
|
||||
name,
|
||||
size,
|
||||
scale,
|
||||
Gtk.IconLookupFlags.FORCE_SYMBOLIC
|
||||
);
|
||||
|
||||
return info.load_symbolic(color, null, null, null)[0];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a localized string for a phone number type
|
||||
* See: http://www.ietf.org/rfc/rfc2426.txt
|
||||
*
|
||||
* @param {string} type - An RFC2426 phone number type
|
||||
* @return {string} A localized string like 'Mobile'
|
||||
*/
|
||||
function getNumberTypeLabel(type) {
|
||||
if (type.includes('fax'))
|
||||
// TRANSLATORS: A fax number
|
||||
return _('Fax');
|
||||
|
||||
if (type.includes('work'))
|
||||
// TRANSLATORS: A work or office phone number
|
||||
return _('Work');
|
||||
|
||||
if (type.includes('cell'))
|
||||
// TRANSLATORS: A mobile or cellular phone number
|
||||
return _('Mobile');
|
||||
|
||||
if (type.includes('home'))
|
||||
// TRANSLATORS: A home phone number
|
||||
return _('Home');
|
||||
|
||||
// TRANSLATORS: All other phone number types
|
||||
return _('Other');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a display number from @contact for @address.
|
||||
*
|
||||
* @param {Object} contact - A contact object
|
||||
* @param {string} address - A phone number
|
||||
* @return {string} A (possibly) better display number for the address
|
||||
*/
|
||||
function getDisplayNumber(contact, address) {
|
||||
const number = address.toPhoneNumber();
|
||||
|
||||
for (const contactNumber of contact.numbers) {
|
||||
const cnumber = contactNumber.value.toPhoneNumber();
|
||||
|
||||
if (number.endsWith(cnumber) || cnumber.endsWith(number))
|
||||
return GLib.markup_escape_text(contactNumber.value, -1);
|
||||
}
|
||||
|
||||
return GLib.markup_escape_text(address, -1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Contact Avatar
|
||||
*/
|
||||
const AvatarCache = new WeakMap();
|
||||
|
||||
var Avatar = GObject.registerClass({
|
||||
GTypeName: 'GSConnectContactAvatar',
|
||||
}, class ContactAvatar extends Gtk.DrawingArea {
|
||||
|
||||
_init(contact = null) {
|
||||
super._init({
|
||||
height_request: 32,
|
||||
width_request: 32,
|
||||
valign: Gtk.Align.CENTER,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
get rgba() {
|
||||
if (this._rgba === undefined) {
|
||||
if (this.contact)
|
||||
this._rgba = randomRGBA(this.contact.name);
|
||||
else
|
||||
this._rgba = randomRGBA(GLib.uuid_string_random());
|
||||
}
|
||||
|
||||
return this._rgba;
|
||||
}
|
||||
|
||||
get contact() {
|
||||
if (this._contact === undefined)
|
||||
this._contact = null;
|
||||
|
||||
return this._contact;
|
||||
}
|
||||
|
||||
set contact(contact) {
|
||||
if (this.contact === contact)
|
||||
return;
|
||||
|
||||
this._contact = contact;
|
||||
this._surface = undefined;
|
||||
this._rgba = undefined;
|
||||
this._offset = 0;
|
||||
}
|
||||
|
||||
_loadSurface() {
|
||||
// Get the monitor scale
|
||||
const display = Gdk.Display.get_default();
|
||||
const monitor = display.get_monitor_at_window(this.get_window());
|
||||
const scale = monitor.get_scale_factor();
|
||||
|
||||
// If there's a contact with an avatar, try to load it
|
||||
if (this.contact && this.contact.avatar) {
|
||||
// Check the cache
|
||||
this._surface = AvatarCache.get(this.contact);
|
||||
|
||||
// Try loading the pixbuf
|
||||
if (!this._surface) {
|
||||
const pixbuf = getPixbufForPath(
|
||||
this.contact.avatar,
|
||||
this.width_request,
|
||||
scale
|
||||
);
|
||||
|
||||
if (pixbuf) {
|
||||
this._surface = Gdk.cairo_surface_create_from_pixbuf(
|
||||
pixbuf,
|
||||
0,
|
||||
this.get_window()
|
||||
);
|
||||
AvatarCache.set(this.contact, this._surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have a surface, load a fallback
|
||||
if (!this._surface) {
|
||||
let iconName;
|
||||
|
||||
// If we were given a contact, it's direct message otherwise group
|
||||
if (this.contact)
|
||||
iconName = 'avatar-default-symbolic';
|
||||
else
|
||||
iconName = 'group-avatar-symbolic';
|
||||
|
||||
// Center the icon
|
||||
this._offset = (this.width_request - 24) / 2;
|
||||
|
||||
// Load the fallback
|
||||
const pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);
|
||||
|
||||
this._surface = Gdk.cairo_surface_create_from_pixbuf(
|
||||
pixbuf,
|
||||
0,
|
||||
this.get_window()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
vfunc_draw(cr) {
|
||||
if (!this._surface)
|
||||
this._loadSurface();
|
||||
|
||||
// Clip to a circle
|
||||
const rad = this.width_request / 2;
|
||||
cr.arc(rad, rad, rad, 0, 2 * Math.PI);
|
||||
cr.clipPreserve();
|
||||
|
||||
// Fill the background if the the surface is offset
|
||||
if (this._offset > 0) {
|
||||
Gdk.cairo_set_source_rgba(cr, this.rgba);
|
||||
cr.fill();
|
||||
}
|
||||
|
||||
// Draw the avatar/icon
|
||||
cr.setSourceSurface(this._surface, this._offset, this._offset);
|
||||
cr.paint();
|
||||
|
||||
cr.$dispose();
|
||||
return Gdk.EVENT_PROPAGATE;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A row for a contact address (usually a phone number).
|
||||
*/
|
||||
const AddressRow = GObject.registerClass({
|
||||
GTypeName: 'GSConnectContactsAddressRow',
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contacts-address-row.ui',
|
||||
Children: ['avatar', 'name-label', 'address-label', 'type-label'],
|
||||
}, class AddressRow extends Gtk.ListBoxRow {
|
||||
|
||||
_init(contact, index = 0) {
|
||||
super._init();
|
||||
|
||||
this._index = index;
|
||||
this._number = contact.numbers[index];
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
get contact() {
|
||||
if (this._contact === undefined)
|
||||
this._contact = null;
|
||||
|
||||
return this._contact;
|
||||
}
|
||||
|
||||
set contact(contact) {
|
||||
if (this.contact === contact)
|
||||
return;
|
||||
|
||||
this._contact = contact;
|
||||
|
||||
if (this._index === 0) {
|
||||
this.avatar.contact = contact;
|
||||
this.avatar.visible = true;
|
||||
|
||||
this.name_label.label = GLib.markup_escape_text(contact.name, -1);
|
||||
this.name_label.visible = true;
|
||||
|
||||
this.address_label.margin_start = 0;
|
||||
this.address_label.margin_end = 0;
|
||||
} else {
|
||||
this.avatar.visible = false;
|
||||
this.name_label.visible = false;
|
||||
|
||||
// TODO: rtl inverts margin-start so the number don't align
|
||||
this.address_label.margin_start = 38;
|
||||
this.address_label.margin_end = 38;
|
||||
}
|
||||
|
||||
this.address_label.label = GLib.markup_escape_text(this.number.value, -1);
|
||||
|
||||
if (this.number.type !== undefined)
|
||||
this.type_label.label = getNumberTypeLabel(this.number.type);
|
||||
}
|
||||
|
||||
get number() {
|
||||
if (this._number === undefined)
|
||||
return {value: 'unknown', type: 'unknown'};
|
||||
|
||||
return this._number;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A widget for selecting contact addresses (usually phone numbers)
|
||||
*/
|
||||
var ContactChooser = GObject.registerClass({
|
||||
GTypeName: 'GSConnectContactChooser',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'store': GObject.ParamSpec.object(
|
||||
'store',
|
||||
'Store',
|
||||
'The contacts store',
|
||||
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
|
||||
GObject.Object
|
||||
),
|
||||
},
|
||||
Signals: {
|
||||
'number-selected': {
|
||||
flags: GObject.SignalFlags.RUN_FIRST,
|
||||
param_types: [GObject.TYPE_STRING],
|
||||
},
|
||||
},
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
|
||||
Children: ['entry', 'list', 'scrolled'],
|
||||
}, class ContactChooser extends Gtk.Grid {
|
||||
|
||||
_init(params) {
|
||||
super._init(params);
|
||||
|
||||
// Setup the contact list
|
||||
this.list._entry = this.entry.text;
|
||||
this.list.set_filter_func(this._filter);
|
||||
this.list.set_sort_func(this._sort);
|
||||
|
||||
// Make sure we're using the correct contacts store
|
||||
this.device.bind_property(
|
||||
'contacts',
|
||||
this,
|
||||
'store',
|
||||
GObject.BindingFlags.SYNC_CREATE
|
||||
);
|
||||
|
||||
// Cleanup on ::destroy
|
||||
this.connect('destroy', this._onDestroy);
|
||||
}
|
||||
|
||||
get store() {
|
||||
if (this._store === undefined)
|
||||
this._store = null;
|
||||
|
||||
return this._store;
|
||||
}
|
||||
|
||||
set store(store) {
|
||||
if (this.store === store)
|
||||
return;
|
||||
|
||||
// Unbind the old store
|
||||
if (this._store) {
|
||||
// Disconnect from the store
|
||||
this._store.disconnect(this._contactAddedId);
|
||||
this._store.disconnect(this._contactRemovedId);
|
||||
this._store.disconnect(this._contactChangedId);
|
||||
|
||||
// Clear the contact list
|
||||
const rows = this.list.get_children();
|
||||
|
||||
for (let i = 0, len = rows.length; i < len; i++) {
|
||||
rows[i].destroy();
|
||||
// HACK: temporary mitigator for mysterious GtkListBox leak
|
||||
imports.system.gc();
|
||||
}
|
||||
}
|
||||
|
||||
// Set the store
|
||||
this._store = store;
|
||||
|
||||
// Bind the new store
|
||||
if (this._store) {
|
||||
// Connect to the new store
|
||||
this._contactAddedId = store.connect(
|
||||
'contact-added',
|
||||
this._onContactAdded.bind(this)
|
||||
);
|
||||
|
||||
this._contactRemovedId = store.connect(
|
||||
'contact-removed',
|
||||
this._onContactRemoved.bind(this)
|
||||
);
|
||||
|
||||
this._contactChangedId = store.connect(
|
||||
'contact-changed',
|
||||
this._onContactChanged.bind(this)
|
||||
);
|
||||
|
||||
// Populate the list
|
||||
this._populate();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ContactStore Callbacks
|
||||
*/
|
||||
_onContactAdded(store, id) {
|
||||
const contact = this.store.get_contact(id);
|
||||
this._addContact(contact);
|
||||
}
|
||||
|
||||
_onContactRemoved(store, id) {
|
||||
const rows = this.list.get_children();
|
||||
|
||||
for (let i = 0, len = rows.length; i < len; i++) {
|
||||
const row = rows[i];
|
||||
|
||||
if (row.contact.id === id) {
|
||||
row.destroy();
|
||||
// HACK: temporary mitigator for mysterious GtkListBox leak
|
||||
imports.system.gc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onContactChanged(store, id) {
|
||||
this._onContactRemoved(store, id);
|
||||
this._onContactAdded(store, id);
|
||||
}
|
||||
|
||||
_onDestroy(chooser) {
|
||||
chooser.store = null;
|
||||
}
|
||||
|
||||
_onSearchChanged(entry) {
|
||||
this.list._entry = entry.text;
|
||||
let dynamic = this.list.get_row_at_index(0);
|
||||
|
||||
// If the entry contains string with 2 or more digits...
|
||||
if (entry.text.replace(/\D/g, '').length >= 2) {
|
||||
// ...ensure we have a dynamic contact for it
|
||||
if (!dynamic || !dynamic.__tmp) {
|
||||
dynamic = new AddressRow({
|
||||
// TRANSLATORS: A phone number (eg. "Send to 555-5555")
|
||||
name: _('Send to %s').format(entry.text),
|
||||
numbers: [{type: 'unknown', value: entry.text}],
|
||||
});
|
||||
dynamic.__tmp = true;
|
||||
this.list.add(dynamic);
|
||||
|
||||
// ...or if we already do, then update it
|
||||
} else {
|
||||
const address = entry.text;
|
||||
|
||||
// Update contact object
|
||||
dynamic.contact.name = address;
|
||||
dynamic.contact.numbers[0].value = address;
|
||||
|
||||
// Update UI
|
||||
dynamic.name_label.label = _('Send to %s').format(address);
|
||||
dynamic.address_label.label = address;
|
||||
}
|
||||
|
||||
// ...otherwise remove any dynamic contact that's been created
|
||||
} else if (dynamic && dynamic.__tmp) {
|
||||
dynamic.destroy();
|
||||
}
|
||||
|
||||
this.list.invalidate_filter();
|
||||
this.list.invalidate_sort();
|
||||
}
|
||||
|
||||
// GtkListBox::row-activated
|
||||
_onNumberSelected(box, row) {
|
||||
if (row === null)
|
||||
return;
|
||||
|
||||
// Emit the number
|
||||
const address = row.number.value;
|
||||
this.emit('number-selected', address);
|
||||
|
||||
// Reset the contact list
|
||||
this.entry.text = '';
|
||||
this.list.select_row(null);
|
||||
this.scrolled.vadjustment.value = 0;
|
||||
}
|
||||
|
||||
_filter(row) {
|
||||
// Dynamic contact always shown
|
||||
if (row.__tmp)
|
||||
return true;
|
||||
|
||||
const query = row.get_parent()._entry;
|
||||
|
||||
// Show contact if text is substring of name
|
||||
const queryName = query.toLocaleLowerCase();
|
||||
|
||||
if (row.contact.name.toLocaleLowerCase().includes(queryName))
|
||||
return true;
|
||||
|
||||
// Show contact if text is substring of number
|
||||
const queryNumber = query.toPhoneNumber();
|
||||
|
||||
if (queryNumber.length) {
|
||||
for (const number of row.contact.numbers) {
|
||||
if (number.value.toPhoneNumber().includes(queryNumber))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Query is effectively empty
|
||||
} else if (/^0+/.test(query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_sort(row1, row2) {
|
||||
if (row1.__tmp)
|
||||
return -1;
|
||||
|
||||
if (row2.__tmp)
|
||||
return 1;
|
||||
|
||||
return row1.contact.name.localeCompare(row2.contact.name);
|
||||
}
|
||||
|
||||
_populate() {
|
||||
// Add each contact
|
||||
const contacts = this.store.contacts;
|
||||
|
||||
for (let i = 0, len = contacts.length; i < len; i++)
|
||||
this._addContact(contacts[i]);
|
||||
}
|
||||
|
||||
_addContactNumber(contact, index) {
|
||||
const row = new AddressRow(contact, index);
|
||||
this.list.add(row);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
_addContact(contact) {
|
||||
try {
|
||||
// HACK: fix missing contact names
|
||||
if (contact.name === undefined)
|
||||
contact.name = _('Unknown Contact');
|
||||
|
||||
if (contact.numbers.length === 1)
|
||||
return this._addContactNumber(contact, 0);
|
||||
|
||||
for (let i = 0, len = contact.numbers.length; i < len; i++)
|
||||
this._addContactNumber(contact, i);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dictionary of number-contact pairs for each selected phone number.
|
||||
*
|
||||
* @return {Object[]} A dictionary of contacts
|
||||
*/
|
||||
getSelected() {
|
||||
try {
|
||||
const selected = {};
|
||||
|
||||
for (const row of this.list.get_selected_rows())
|
||||
selected[row.number.value] = row.contact;
|
||||
|
||||
return selected;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Contacts = imports.service.ui.contacts;
|
||||
const Messaging = imports.service.ui.messaging;
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
var Dialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectLegacyMessagingDialog',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'plugin': GObject.ParamSpec.object(
|
||||
'plugin',
|
||||
'Plugin',
|
||||
'The plugin providing messages',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
},
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/legacy-messaging-dialog.ui',
|
||||
Children: [
|
||||
'infobar', 'stack',
|
||||
'message-box', 'message-avatar', 'message-label', 'entry',
|
||||
],
|
||||
}, class Dialog extends Gtk.Dialog {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
application: Gio.Application.get_default(),
|
||||
device: params.device,
|
||||
plugin: params.plugin,
|
||||
use_header_bar: true,
|
||||
});
|
||||
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, false);
|
||||
|
||||
// Dup some functions
|
||||
this.headerbar = this.get_titlebar();
|
||||
this._setHeaderBar = Messaging.Window.prototype._setHeaderBar;
|
||||
|
||||
// Info bar
|
||||
this.device.bind_property(
|
||||
'connected',
|
||||
this.infobar,
|
||||
'reveal-child',
|
||||
GObject.BindingFlags.INVERT_BOOLEAN
|
||||
);
|
||||
|
||||
// Message Entry/Send Button
|
||||
this.device.bind_property(
|
||||
'connected',
|
||||
this.entry,
|
||||
'sensitive',
|
||||
GObject.BindingFlags.DEFAULT
|
||||
);
|
||||
|
||||
this._connectedId = this.device.connect(
|
||||
'notify::connected',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
this._entryChangedId = this.entry.buffer.connect(
|
||||
'changed',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
// Set the message if given
|
||||
if (params.message) {
|
||||
this.message = params.message;
|
||||
this.addresses = params.message.addresses;
|
||||
|
||||
this.message_avatar.contact = this.device.contacts.query({
|
||||
number: this.addresses[0].address,
|
||||
});
|
||||
this.message_label.label = URI.linkify(this.message.body);
|
||||
this.message_box.visible = true;
|
||||
|
||||
// Otherwise set the address(es) if we were passed those
|
||||
} else if (params.addresses) {
|
||||
this.addresses = params.addresses;
|
||||
}
|
||||
|
||||
// Load the contact list if we weren't supplied with an address
|
||||
if (this.addresses.length === 0) {
|
||||
this.contact_chooser = new Contacts.ContactChooser({
|
||||
device: this.device,
|
||||
});
|
||||
this.stack.add_named(this.contact_chooser, 'contact-chooser');
|
||||
this.stack.child_set_property(this.contact_chooser, 'position', 0);
|
||||
|
||||
this._numberSelectedId = this.contact_chooser.connect(
|
||||
'number-selected',
|
||||
this._onNumberSelected.bind(this)
|
||||
);
|
||||
|
||||
this.stack.visible_child_name = 'contact-chooser';
|
||||
}
|
||||
|
||||
this.restoreGeometry('legacy-messaging-dialog');
|
||||
|
||||
this.connect('destroy', this._onDestroy);
|
||||
}
|
||||
|
||||
_onDestroy(dialog) {
|
||||
if (dialog._numberSelectedId !== undefined) {
|
||||
dialog.contact_chooser.disconnect(dialog._numberSelectedId);
|
||||
dialog.contact_chooser.destroy();
|
||||
}
|
||||
|
||||
dialog.entry.buffer.disconnect(dialog._entryChangedId);
|
||||
dialog.device.disconnect(dialog._connectedId);
|
||||
}
|
||||
|
||||
vfunc_delete_event() {
|
||||
this.saveGeometry();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
vfunc_response(response_id) {
|
||||
if (response_id === Gtk.ResponseType.OK) {
|
||||
// Refuse to send empty or whitespace only texts
|
||||
if (!this.entry.buffer.text.trim())
|
||||
return;
|
||||
|
||||
this.plugin.sendMessage(
|
||||
this.addresses,
|
||||
this.entry.buffer.text,
|
||||
1,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
get addresses() {
|
||||
if (this._addresses === undefined)
|
||||
this._addresses = [];
|
||||
|
||||
return this._addresses;
|
||||
}
|
||||
|
||||
set addresses(addresses = []) {
|
||||
this._addresses = addresses;
|
||||
|
||||
// Set the headerbar
|
||||
this._setHeaderBar(this._addresses);
|
||||
|
||||
// Show the message editor
|
||||
this.stack.visible_child_name = 'message-editor';
|
||||
this._onStateChanged();
|
||||
}
|
||||
|
||||
get device() {
|
||||
if (this._device === undefined)
|
||||
this._device = null;
|
||||
|
||||
return this._device;
|
||||
}
|
||||
|
||||
set device(device) {
|
||||
this._device = device;
|
||||
}
|
||||
|
||||
get plugin() {
|
||||
if (this._plugin === undefined)
|
||||
this._plugin = null;
|
||||
|
||||
return this._plugin;
|
||||
}
|
||||
|
||||
set plugin(plugin) {
|
||||
this._plugin = plugin;
|
||||
}
|
||||
|
||||
_onActivateLink(label, uri) {
|
||||
Gtk.show_uri_on_window(
|
||||
this.get_toplevel(),
|
||||
uri.includes('://') ? uri : `https://${uri}`,
|
||||
Gtk.get_current_event_time()
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_onNumberSelected(chooser, number) {
|
||||
const contacts = chooser.getSelected();
|
||||
|
||||
this.addresses = Object.keys(contacts).map(address => {
|
||||
return {address: address};
|
||||
});
|
||||
}
|
||||
|
||||
_onStateChanged() {
|
||||
if (this.device.connected &&
|
||||
this.entry.buffer.text.trim() &&
|
||||
this.stack.visible_child_name === 'message-editor')
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, true);
|
||||
else
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents of the message entry
|
||||
*
|
||||
* @param {string} text - The message to place in the entry
|
||||
*/
|
||||
setMessage(text) {
|
||||
this.entry.buffer.text = text;
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
|
||||
/**
|
||||
* 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 InputDialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMousepadInputDialog',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'plugin': GObject.ParamSpec.object(
|
||||
'plugin',
|
||||
'Plugin',
|
||||
'The mousepad plugin associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
},
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/mousepad-input-dialog.ui',
|
||||
Children: [
|
||||
'infobar', 'infobar-label',
|
||||
'shift-label', 'ctrl-label', 'alt-label', 'super-label', 'entry',
|
||||
],
|
||||
}, class InputDialog extends Gtk.Dialog {
|
||||
|
||||
_init(params) {
|
||||
super._init(Object.assign({
|
||||
use_header_bar: true,
|
||||
}, params));
|
||||
|
||||
const headerbar = this.get_titlebar();
|
||||
headerbar.title = _('Keyboard');
|
||||
headerbar.subtitle = this.device.name;
|
||||
|
||||
// Main Box
|
||||
const content = this.get_content_area();
|
||||
content.border_width = 0;
|
||||
|
||||
// TRANSLATORS: Displayed when the remote keyboard is not ready to accept input
|
||||
this.infobar_label.label = _('Remote keyboard on %s is not active').format(this.device.name);
|
||||
|
||||
// Text Input
|
||||
this.entry.buffer.connect(
|
||||
'insert-text',
|
||||
this._onInsertText.bind(this)
|
||||
);
|
||||
|
||||
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_grab_broken_event(event) {
|
||||
if (event.keyboard)
|
||||
this._ungrab();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
vfunc_key_release_event(event) {
|
||||
if (!this.plugin.state)
|
||||
debug('ignoring remote keyboard state');
|
||||
|
||||
const keyvalLower = Gdk.keyval_to_lower(event.keyval);
|
||||
const 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)
|
||||
debug('ignoring remote keyboard state');
|
||||
|
||||
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)
|
||||
return false;
|
||||
|
||||
debug(`keyval: ${event.keyval}, mask: ${realMask}`);
|
||||
|
||||
const 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 {
|
||||
const 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)
|
||||
debug('ignoring remote keyboard state');
|
||||
|
||||
if (event.new_window_state & Gdk.WindowState.FOCUSED)
|
||||
this._grab();
|
||||
else
|
||||
this._ungrab();
|
||||
|
||||
return super.vfunc_window_state_event(event);
|
||||
}
|
||||
|
||||
_onInsertText(buffer, location, text, len) {
|
||||
if (this._isAck)
|
||||
return;
|
||||
|
||||
debug(`insert-text: ${text} (chars ${[...text].length})`);
|
||||
|
||||
for (const char of [...text]) {
|
||||
if (!char)
|
||||
continue;
|
||||
|
||||
// TODO: modifiers?
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mousepad.request',
|
||||
body: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
super: false,
|
||||
sendAck: false,
|
||||
key: char,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onState(widget) {
|
||||
if (!this.plugin.state)
|
||||
debug('ignoring remote keyboard state');
|
||||
|
||||
if (this.is_active)
|
||||
this._grab();
|
||||
else
|
||||
this._ungrab();
|
||||
}
|
||||
|
||||
_grab() {
|
||||
if (!this.visible || this._keyboard)
|
||||
return;
|
||||
|
||||
const seat = Gdk.Display.get_default().get_default_seat();
|
||||
const 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._keyboard = seat.get_keyboard();
|
||||
this.grab_add();
|
||||
this.entry.has_focus = true;
|
||||
}
|
||||
|
||||
_ungrab() {
|
||||
if (this._keyboard) {
|
||||
this._keyboard.get_seat().ungrab();
|
||||
this._keyboard = null;
|
||||
this.grab_remove();
|
||||
}
|
||||
|
||||
this.entry.buffer.text = '';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
/**
|
||||
* A dialog for repliable notifications.
|
||||
*/
|
||||
var ReplyDialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNotificationReplyDialog',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'plugin': GObject.ParamSpec.object(
|
||||
'plugin',
|
||||
'Plugin',
|
||||
'The plugin that owns this notification',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'uuid': GObject.ParamSpec.string(
|
||||
'uuid',
|
||||
'UUID',
|
||||
'The notification reply UUID',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
},
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/notification-reply-dialog.ui',
|
||||
Children: ['infobar', 'notification-title', 'notification-body', 'entry'],
|
||||
}, class ReplyDialog extends Gtk.Dialog {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
application: Gio.Application.get_default(),
|
||||
device: params.device,
|
||||
plugin: params.plugin,
|
||||
uuid: params.uuid,
|
||||
use_header_bar: true,
|
||||
});
|
||||
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, false);
|
||||
|
||||
// Info bar
|
||||
this.device.bind_property(
|
||||
'connected',
|
||||
this.infobar,
|
||||
'reveal-child',
|
||||
GObject.BindingFlags.INVERT_BOOLEAN
|
||||
);
|
||||
|
||||
// Notification Data
|
||||
const headerbar = this.get_titlebar();
|
||||
headerbar.title = params.notification.appName;
|
||||
headerbar.subtitle = this.device.name;
|
||||
|
||||
this.notification_title.label = params.notification.title;
|
||||
this.notification_body.label = URI.linkify(params.notification.text);
|
||||
|
||||
// Message Entry/Send Button
|
||||
this.device.bind_property(
|
||||
'connected',
|
||||
this.entry,
|
||||
'sensitive',
|
||||
GObject.BindingFlags.DEFAULT
|
||||
);
|
||||
|
||||
this._connectedId = this.device.connect(
|
||||
'notify::connected',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
this._entryChangedId = this.entry.buffer.connect(
|
||||
'changed',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
this.restoreGeometry('notification-reply-dialog');
|
||||
|
||||
this.connect('destroy', this._onDestroy);
|
||||
}
|
||||
|
||||
_onDestroy(dialog) {
|
||||
dialog.entry.buffer.disconnect(dialog._entryChangedId);
|
||||
dialog.device.disconnect(dialog._connectedId);
|
||||
}
|
||||
|
||||
vfunc_delete_event() {
|
||||
this.saveGeometry();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
vfunc_response(response_id) {
|
||||
if (response_id === Gtk.ResponseType.OK) {
|
||||
// Refuse to send empty or whitespace only messages
|
||||
if (!this.entry.buffer.text.trim())
|
||||
return;
|
||||
|
||||
this.plugin.replyNotification(
|
||||
this.uuid,
|
||||
this.entry.buffer.text
|
||||
);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
get device() {
|
||||
if (this._device === undefined)
|
||||
this._device = null;
|
||||
|
||||
return this._device;
|
||||
}
|
||||
|
||||
set device(device) {
|
||||
this._device = device;
|
||||
}
|
||||
|
||||
get plugin() {
|
||||
if (this._plugin === undefined)
|
||||
this._plugin = null;
|
||||
|
||||
return this._plugin;
|
||||
}
|
||||
|
||||
set plugin(plugin) {
|
||||
this._plugin = plugin;
|
||||
}
|
||||
|
||||
get uuid() {
|
||||
if (this._uuid === undefined)
|
||||
this._uuid = null;
|
||||
|
||||
return this._uuid;
|
||||
}
|
||||
|
||||
set uuid(uuid) {
|
||||
this._uuid = uuid;
|
||||
|
||||
// We must have a UUID
|
||||
if (!uuid) {
|
||||
this.destroy();
|
||||
debug('no uuid for repliable notification');
|
||||
}
|
||||
}
|
||||
|
||||
_onActivateLink(label, uri) {
|
||||
Gtk.show_uri_on_window(
|
||||
this.get_toplevel(),
|
||||
uri.includes('://') ? uri : `https://${uri}`,
|
||||
Gtk.get_current_event_time()
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_onStateChanged() {
|
||||
if (this.device.connected && this.entry.buffer.text.trim())
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, true);
|
||||
else
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use strict';
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Config = imports.config;
|
||||
|
||||
|
||||
/*
|
||||
* Issue Header
|
||||
*/
|
||||
const ISSUE_HEADER = `
|
||||
GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'})
|
||||
GJS: ${imports.system.version}
|
||||
Session: ${GLib.getenv('XDG_SESSION_TYPE')}
|
||||
OS: ${GLib.get_os_info('PRETTY_NAME')}
|
||||
`;
|
||||
|
||||
|
||||
/**
|
||||
* A dialog for selecting a device
|
||||
*/
|
||||
var DeviceChooser = GObject.registerClass({
|
||||
GTypeName: 'GSConnectServiceDeviceChooser',
|
||||
Properties: {
|
||||
'action-name': GObject.ParamSpec.string(
|
||||
'action-name',
|
||||
'Action Name',
|
||||
'The name of the associated action, like "sendFile"',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
null
|
||||
),
|
||||
'action-target': GObject.param_spec_variant(
|
||||
'action-target',
|
||||
'Action Target',
|
||||
'The parameter for action invocations',
|
||||
new GLib.VariantType('*'),
|
||||
null,
|
||||
GObject.ParamFlags.READWRITE
|
||||
),
|
||||
},
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/service-device-chooser.ui',
|
||||
Children: ['device-list', 'cancel-button', 'select-button'],
|
||||
}, class DeviceChooser extends Gtk.Dialog {
|
||||
|
||||
_init(params = {}) {
|
||||
super._init({
|
||||
use_header_bar: true,
|
||||
application: Gio.Application.get_default(),
|
||||
});
|
||||
this.set_keep_above(true);
|
||||
|
||||
// HeaderBar
|
||||
this.get_header_bar().subtitle = params.title;
|
||||
|
||||
// Dialog Action
|
||||
this.action_name = params.action_name;
|
||||
this.action_target = params.action_target;
|
||||
|
||||
// Device List
|
||||
this.device_list.set_sort_func(this._sortDevices);
|
||||
|
||||
this._devicesChangedId = this.application.settings.connect(
|
||||
'changed::devices',
|
||||
this._onDevicesChanged.bind(this)
|
||||
);
|
||||
this._onDevicesChanged();
|
||||
}
|
||||
|
||||
vfunc_response(response_id) {
|
||||
if (response_id === Gtk.ResponseType.OK) {
|
||||
try {
|
||||
const device = this.device_list.get_selected_row().device;
|
||||
device.activate_action(this.action_name, this.action_target);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
get action_name() {
|
||||
if (this._action_name === undefined)
|
||||
this._action_name = null;
|
||||
|
||||
return this._action_name;
|
||||
}
|
||||
|
||||
set action_name(name) {
|
||||
this._action_name = name;
|
||||
}
|
||||
|
||||
get action_target() {
|
||||
if (this._action_target === undefined)
|
||||
this._action_target = null;
|
||||
|
||||
return this._action_target;
|
||||
}
|
||||
|
||||
set action_target(variant) {
|
||||
this._action_target = variant;
|
||||
}
|
||||
|
||||
_onDeviceActivated(box, row) {
|
||||
this.response(Gtk.ResponseType.OK);
|
||||
}
|
||||
|
||||
_onDeviceSelected(box) {
|
||||
this.set_response_sensitive(
|
||||
Gtk.ResponseType.OK,
|
||||
(box.get_selected_row())
|
||||
);
|
||||
}
|
||||
|
||||
_onDevicesChanged() {
|
||||
// Collect known devices
|
||||
const devices = {};
|
||||
|
||||
for (const [id, device] of this.application.manager.devices.entries())
|
||||
devices[id] = device;
|
||||
|
||||
// Prune device rows
|
||||
this.device_list.foreach(row => {
|
||||
if (!devices.hasOwnProperty(row.name))
|
||||
row.destroy();
|
||||
else
|
||||
delete devices[row.name];
|
||||
});
|
||||
|
||||
// Add new devices
|
||||
for (const device of Object.values(devices)) {
|
||||
const action = device.lookup_action(this.action_name);
|
||||
|
||||
if (action === null)
|
||||
continue;
|
||||
|
||||
const row = new Gtk.ListBoxRow({
|
||||
visible: action.enabled,
|
||||
});
|
||||
row.set_name(device.id);
|
||||
row.device = device;
|
||||
|
||||
action.bind_property(
|
||||
'enabled',
|
||||
row,
|
||||
'visible',
|
||||
Gio.SettingsBindFlags.DEFAULT
|
||||
);
|
||||
|
||||
const grid = new Gtk.Grid({
|
||||
column_spacing: 12,
|
||||
margin: 6,
|
||||
visible: true,
|
||||
});
|
||||
row.add(grid);
|
||||
|
||||
const icon = new Gtk.Image({
|
||||
icon_name: device.icon_name,
|
||||
pixel_size: 32,
|
||||
visible: true,
|
||||
});
|
||||
grid.attach(icon, 0, 0, 1, 1);
|
||||
|
||||
const name = new Gtk.Label({
|
||||
label: device.name,
|
||||
halign: Gtk.Align.START,
|
||||
hexpand: true,
|
||||
visible: true,
|
||||
});
|
||||
grid.attach(name, 1, 0, 1, 1);
|
||||
|
||||
this.device_list.add(row);
|
||||
}
|
||||
|
||||
if (this.device_list.get_selected_row() === null)
|
||||
this.device_list.select_row(this.device_list.get_row_at_index(0));
|
||||
}
|
||||
|
||||
_sortDevices(row1, row2) {
|
||||
return row1.device.name.localeCompare(row2.device.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A dialog for reporting an error.
|
||||
*/
|
||||
var ErrorDialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectServiceErrorDialog',
|
||||
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/service-error-dialog.ui',
|
||||
Children: [
|
||||
'error-stack',
|
||||
'expander-arrow',
|
||||
'gesture',
|
||||
'report-button',
|
||||
'revealer',
|
||||
],
|
||||
}, class ErrorDialog extends Gtk.Window {
|
||||
|
||||
_init(error) {
|
||||
super._init({
|
||||
application: Gio.Application.get_default(),
|
||||
title: `GSConnect: ${error.name}`,
|
||||
});
|
||||
this.set_keep_above(true);
|
||||
|
||||
this.error = error;
|
||||
this.error_stack.buffer.text = `${error.message}\n\n${error.stack}`;
|
||||
this.gesture.connect('released', this._onReleased.bind(this));
|
||||
}
|
||||
|
||||
_onClicked(button) {
|
||||
if (this.report_button === button) {
|
||||
const uri = this._buildUri(this.error.message, this.error.stack);
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
_onReleased(gesture, n_press) {
|
||||
if (n_press === 1)
|
||||
this.revealer.reveal_child = !this.revealer.reveal_child;
|
||||
}
|
||||
|
||||
_onRevealChild(revealer, pspec) {
|
||||
this.expander_arrow.icon_name = this.revealer.reveal_child
|
||||
? 'pan-down-symbolic'
|
||||
: 'pan-end-symbolic';
|
||||
}
|
||||
|
||||
_buildUri(message, stack) {
|
||||
const body = `\`\`\`${ISSUE_HEADER}\n${stack}\n\`\`\``;
|
||||
const titleQuery = encodeURIComponent(message).replace('%20', '+');
|
||||
const bodyQuery = encodeURIComponent(body).replace('%20', '+');
|
||||
const uri = `${Config.PACKAGE_BUGREPORT}?title=${titleQuery}&body=${bodyQuery}`;
|
||||
|
||||
// Reasonable URI length limit
|
||||
if (uri.length > 2000)
|
||||
return uri.substr(0, 2000);
|
||||
|
||||
return uri;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GjsPrivate = imports.gi.GjsPrivate;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
|
||||
/*
|
||||
* Some utility methods
|
||||
*/
|
||||
function toDBusCase(string) {
|
||||
return string.replace(/(?:^\w|[A-Z]|\b\w)/g, (ltr, offset) => {
|
||||
return ltr.toUpperCase();
|
||||
}).replace(/[\s_-]+/g, '');
|
||||
}
|
||||
|
||||
function toHyphenCase(string) {
|
||||
return string.replace(/(?:[A-Z])/g, (ltr, offset) => {
|
||||
return (offset > 0) ? `-${ltr.toLowerCase()}` : ltr.toLowerCase();
|
||||
}).replace(/[\s_]+/g, '');
|
||||
}
|
||||
|
||||
function toUnderscoreCase(string) {
|
||||
return string.replace(/(?:^\w|[A-Z]|_|\b\w)/g, (ltr, offset) => {
|
||||
if (ltr === '_')
|
||||
return '';
|
||||
|
||||
return (offset > 0) ? `_${ltr.toLowerCase()}` : ltr.toLowerCase();
|
||||
}).replace(/[\s-]+/g, '');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* DBus.Interface represents a DBus interface bound to an object instance, meant
|
||||
* to be exported over DBus.
|
||||
*/
|
||||
var Interface = GObject.registerClass({
|
||||
GTypeName: 'GSConnectDBusInterface',
|
||||
Implements: [Gio.DBusInterface],
|
||||
Properties: {
|
||||
'g-instance': GObject.ParamSpec.object(
|
||||
'g-instance',
|
||||
'Instance',
|
||||
'The delegate GObject',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object.$gtype
|
||||
),
|
||||
},
|
||||
}, class Interface extends GjsPrivate.DBusImplementation {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
g_instance: params.g_instance,
|
||||
g_interface_info: params.g_interface_info,
|
||||
});
|
||||
|
||||
// Cache member lookups
|
||||
this._instanceHandlers = [];
|
||||
this._instanceMethods = {};
|
||||
this._instanceProperties = {};
|
||||
|
||||
const info = this.get_info();
|
||||
this.connect('handle-method-call', this._call.bind(this._instance, info));
|
||||
this.connect('handle-property-get', this._get.bind(this._instance, info));
|
||||
this.connect('handle-property-set', this._set.bind(this._instance, info));
|
||||
|
||||
// Automatically forward known signals
|
||||
const id = this._instance.connect('notify', this._notify.bind(this));
|
||||
this._instanceHandlers.push(id);
|
||||
|
||||
for (const signal of info.signals) {
|
||||
const type = `(${signal.args.map(arg => arg.signature).join('')})`;
|
||||
const id = this._instance.connect(
|
||||
signal.name,
|
||||
this._emit.bind(this, signal.name, type)
|
||||
);
|
||||
|
||||
this._instanceHandlers.push(id);
|
||||
}
|
||||
|
||||
// Export if connection and object path were given
|
||||
if (params.g_connection && params.g_object_path)
|
||||
this.export(params.g_connection, params.g_object_path);
|
||||
}
|
||||
|
||||
get g_instance() {
|
||||
if (this._instance === undefined)
|
||||
this._instance = null;
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
set g_instance(instance) {
|
||||
this._instance = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke an instance's method for a DBus method call.
|
||||
*
|
||||
* @param {Gio.DBusInterfaceInfo} info - The DBus interface
|
||||
* @param {DBus.Interface} iface - The DBus interface
|
||||
* @param {string} name - The DBus method name
|
||||
* @param {GLib.Variant} parameters - The method parameters
|
||||
* @param {Gio.DBusMethodInvocation} invocation - The method invocation info
|
||||
*/
|
||||
async _call(info, iface, name, parameters, invocation) {
|
||||
let retval;
|
||||
|
||||
// Invoke the instance method
|
||||
try {
|
||||
const args = parameters.unpack().map(parameter => {
|
||||
if (parameter.get_type_string() === 'h') {
|
||||
const message = invocation.get_message();
|
||||
const fds = message.get_unix_fd_list();
|
||||
const idx = parameter.deepUnpack();
|
||||
return fds.get(idx);
|
||||
} else {
|
||||
return parameter.recursiveUnpack();
|
||||
}
|
||||
});
|
||||
|
||||
retval = await this[name](...args);
|
||||
} catch (e) {
|
||||
if (e instanceof GLib.Error) {
|
||||
invocation.return_gerror(e);
|
||||
} else {
|
||||
// likely to be a normal JS error
|
||||
if (!e.name.includes('.'))
|
||||
e.name = `org.gnome.gjs.JSError.${e.name}`;
|
||||
|
||||
invocation.return_dbus_error(e.name, e.message);
|
||||
}
|
||||
|
||||
logError(e, `${this}: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// `undefined` is an empty tuple on DBus
|
||||
if (retval === undefined)
|
||||
retval = new GLib.Variant('()', []);
|
||||
|
||||
// Return the instance result or error
|
||||
try {
|
||||
if (!(retval instanceof GLib.Variant)) {
|
||||
const args = info.lookup_method(name).out_args;
|
||||
retval = new GLib.Variant(
|
||||
`(${args.map(arg => arg.signature).join('')})`,
|
||||
(args.length === 1) ? [retval] : retval
|
||||
);
|
||||
}
|
||||
|
||||
invocation.return_value(retval);
|
||||
} catch (e) {
|
||||
invocation.return_dbus_error(
|
||||
'org.gnome.gjs.JSError.ValueError',
|
||||
'Service implementation returned an incorrect value type'
|
||||
);
|
||||
|
||||
logError(e, `${this}: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
_nativeProp(obj, name) {
|
||||
if (this._instanceProperties[name] === undefined) {
|
||||
let propName = name;
|
||||
|
||||
if (propName in obj)
|
||||
this._instanceProperties[name] = propName;
|
||||
|
||||
if (this._instanceProperties[name] === undefined) {
|
||||
propName = toUnderscoreCase(name);
|
||||
|
||||
if (propName in obj)
|
||||
this._instanceProperties[name] = propName;
|
||||
}
|
||||
}
|
||||
|
||||
return this._instanceProperties[name];
|
||||
}
|
||||
|
||||
_emit(name, type, obj, ...args) {
|
||||
this.emit_signal(name, new GLib.Variant(type, args));
|
||||
}
|
||||
|
||||
_get(info, iface, name) {
|
||||
const nativeValue = this[iface._nativeProp(this, name)];
|
||||
const propertyInfo = info.lookup_property(name);
|
||||
|
||||
if (nativeValue === undefined || propertyInfo === null)
|
||||
return null;
|
||||
|
||||
return new GLib.Variant(propertyInfo.signature, nativeValue);
|
||||
}
|
||||
|
||||
_set(info, iface, name, value) {
|
||||
const nativeValue = value.recursiveUnpack();
|
||||
|
||||
this[iface._nativeProp(this, name)] = nativeValue;
|
||||
}
|
||||
|
||||
_notify(obj, pspec) {
|
||||
const name = toDBusCase(pspec.name);
|
||||
const propertyInfo = this.get_info().lookup_property(name);
|
||||
|
||||
if (propertyInfo === null)
|
||||
return;
|
||||
|
||||
this.emit_property_changed(
|
||||
name,
|
||||
new GLib.Variant(
|
||||
propertyInfo.signature,
|
||||
// Adjust for GJS's '-'/'_' conversion
|
||||
this._instance[pspec.name.replace(/-/gi, '_')]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
for (const id of this._instanceHandlers)
|
||||
this._instance.disconnect(id);
|
||||
this._instanceHandlers = [];
|
||||
|
||||
this.flush();
|
||||
this.unexport();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Get the DBus connection on @busType
|
||||
*
|
||||
* @param {Gio.BusType} [busType] - a Gio.BusType constant
|
||||
* @param {Gio.Cancellable} [cancellable] - an optional Gio.Cancellable
|
||||
* @return {Promise<Gio.DBusConnection>} A DBus connection
|
||||
*/
|
||||
function getConnection(busType = Gio.BusType.SESSION, cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Gio.bus_get(busType, cancellable, (connection, res) => {
|
||||
try {
|
||||
resolve(Gio.bus_get_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new, dedicated DBus connection on @busType
|
||||
*
|
||||
* @param {Gio.BusType} [busType] - a Gio.BusType constant
|
||||
* @param {Gio.Cancellable} [cancellable] - an optional Gio.Cancellable
|
||||
* @return {Promise<Gio.DBusConnection>} A new DBus connection
|
||||
*/
|
||||
function newConnection(busType = Gio.BusType.SESSION, cancellable = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Gio.DBusConnection.new_for_address(
|
||||
Gio.dbus_address_get_for_bus_sync(busType, cancellable),
|
||||
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
|
||||
Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
|
||||
null,
|
||||
cancellable,
|
||||
(connection, res) => {
|
||||
try {
|
||||
resolve(Gio.DBusConnection.new_for_address_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
|
||||
|
||||
/**
|
||||
* The same regular expression used in GNOME Shell
|
||||
*
|
||||
* http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||
*/
|
||||
const _balancedParens = '\\((?:[^\\s()<>]+|(?:\\(?:[^\\s()<>]+\\)))*\\)';
|
||||
const _leadingJunk = '[\\s`(\\[{\'\\"<\u00AB\u201C\u2018]';
|
||||
const _notTrailingJunk = '[^\\s`!()\\[\\]{};:\'\\".,<>?\u00AB\u00BB\u201C\u201D\u2018\u2019]';
|
||||
|
||||
const _urlRegexp = new RegExp(
|
||||
'(^|' + _leadingJunk + ')' +
|
||||
'(' +
|
||||
'(?:' +
|
||||
'(?:http|https)://' + // scheme://
|
||||
'|' +
|
||||
'www\\d{0,3}[.]' + // www.
|
||||
'|' +
|
||||
'[a-z0-9.\\-]+[.][a-z]{2,4}/' + // foo.xx/
|
||||
')' +
|
||||
'(?:' + // one or more:
|
||||
'[^\\s()<>]+' + // run of non-space non-()
|
||||
'|' + // or
|
||||
_balancedParens + // balanced parens
|
||||
')+' +
|
||||
'(?:' + // end with:
|
||||
_balancedParens + // balanced parens
|
||||
'|' + // or
|
||||
_notTrailingJunk + // last non-junk char
|
||||
')' +
|
||||
')', 'gi');
|
||||
|
||||
|
||||
/**
|
||||
* sms/tel URI RegExp (https://tools.ietf.org/html/rfc5724)
|
||||
*
|
||||
* A fairly lenient regexp for sms: URIs that allows tel: numbers with chars
|
||||
* from global-number, local-number (without phone-context) and single spaces.
|
||||
* This allows passing numbers directly from libfolks or GData without
|
||||
* pre-processing. It also makes an allowance for URIs passed from Gio.File
|
||||
* that always come in the form "sms:///".
|
||||
*/
|
||||
const _smsParam = "[\\w.!~*'()-]+=(?:[\\w.!~*'()-]|%[0-9A-F]{2})*";
|
||||
const _telParam = ";[a-zA-Z0-9-]+=(?:[\\w\\[\\]/:&+$.!~*'()-]|%[0-9A-F]{2})+";
|
||||
const _lenientDigits = '[+]?(?:[0-9A-F*#().-]| (?! )|%20(?!%20))+';
|
||||
const _lenientNumber = `${_lenientDigits}(?:${_telParam})*`;
|
||||
|
||||
const _smsRegex = new RegExp(
|
||||
'^' +
|
||||
'sms:' + // scheme
|
||||
'(?:[/]{2,3})?' + // Gio.File returns ":///"
|
||||
'(' + // one or more...
|
||||
_lenientNumber + // phone numbers
|
||||
'(?:,' + _lenientNumber + ')*' + // separated by commas
|
||||
')' +
|
||||
'(?:\\?(' + // followed by optional...
|
||||
_smsParam + // parameters...
|
||||
'(?:&' + _smsParam + ')*' + // separated by "&" (unescaped)
|
||||
'))?' +
|
||||
'$', 'g'); // fragments (#foo) not allowed
|
||||
|
||||
|
||||
const _numberRegex = new RegExp(
|
||||
'^' +
|
||||
'(' + _lenientDigits + ')' + // phone number digits
|
||||
'((?:' + _telParam + ')*)' + // followed by optional parameters
|
||||
'$', 'g');
|
||||
|
||||
|
||||
/**
|
||||
* Searches @str for URLs and returns an array of objects with %url
|
||||
* properties showing the matched URL string, and %pos properties indicating
|
||||
* the position within @str where the URL was found.
|
||||
*
|
||||
* @param {string} str - the string to search
|
||||
* @return {Object[]} the list of match objects, as described above
|
||||
*/
|
||||
function findUrls(str) {
|
||||
_urlRegexp.lastIndex = 0;
|
||||
|
||||
const res = [];
|
||||
let match;
|
||||
|
||||
while ((match = _urlRegexp.exec(str))) {
|
||||
const name = match[2];
|
||||
const url = GLib.uri_parse_scheme(name) ? name : `http://${name}`;
|
||||
res.push({name, url, pos: match.index + match[1].length});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a string with URLs couched in <a> tags, parseable by Pango and
|
||||
* using the same RegExp as GNOME Shell.
|
||||
*
|
||||
* @param {string} str - The string to be modified
|
||||
* @param {string} [title] - An optional title (eg. alt text, tooltip)
|
||||
* @return {string} the modified text
|
||||
*/
|
||||
function linkify(str, title = null) {
|
||||
const text = GLib.markup_escape_text(str, -1);
|
||||
|
||||
_urlRegexp.lastIndex = 0;
|
||||
|
||||
if (title) {
|
||||
return text.replace(
|
||||
_urlRegexp,
|
||||
`$1<a href="$2" title="${title}">$2</a>`
|
||||
);
|
||||
} else {
|
||||
return text.replace(_urlRegexp, '$1<a href="$2">$2</a>');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A simple parsing class for sms: URI's (https://tools.ietf.org/html/rfc5724)
|
||||
*/
|
||||
var SmsURI = class URI {
|
||||
constructor(uri) {
|
||||
_smsRegex.lastIndex = 0;
|
||||
const [, recipients, query] = _smsRegex.exec(uri);
|
||||
|
||||
this.recipients = recipients.split(',').map(recipient => {
|
||||
_numberRegex.lastIndex = 0;
|
||||
const [, number, params] = _numberRegex.exec(recipient);
|
||||
|
||||
if (params) {
|
||||
for (const param of params.substr(1).split(';')) {
|
||||
const [key, value] = param.split('=');
|
||||
|
||||
// add phone-context to beginning of
|
||||
if (key === 'phone-context' && value.startsWith('+'))
|
||||
return value + unescape(number);
|
||||
}
|
||||
}
|
||||
|
||||
return unescape(number);
|
||||
});
|
||||
|
||||
if (query) {
|
||||
for (const field of query.split('&')) {
|
||||
const [key, value] = field.split('=');
|
||||
|
||||
if (key === 'body') {
|
||||
if (this.body)
|
||||
throw URIError('duplicate "body" field');
|
||||
|
||||
this.body = value ? decodeURIComponent(value) : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
const uri = `sms:${this.recipients.join(',')}`;
|
||||
|
||||
return this.body ? `${uri}?body=${escape(this.body)}` : uri;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user