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

View File

@@ -0,0 +1,889 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
import * as Core from '../core.js';
// Retain compatibility with GLib < 2.80, which lacks GioUnix
let GioUnix;
try {
GioUnix = (await import('gi://GioUnix')).default;
} catch (e) {
GioUnix = {
InputStream: Gio.UnixInputStream,
OutputStream: Gio.UnixOutputStream,
};
}
/**
* TCP Port Constants
*/
const PROTOCOL_PORT_DEFAULT = 1716;
const PROTOCOL_PORT_MIN = 1716;
const PROTOCOL_PORT_MAX = 1764;
const TRANSFER_MIN = 1739;
const TRANSFER_MAX = 1764;
/*
* One-time check for Linux/FreeBSD socket options
*/
export let _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
*/
export 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.
*/
export const 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,
PROTOCOL_PORT_DEFAULT
),
},
}, 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._tcpPort = PROTOCOL_PORT_DEFAULT;
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 = PROTOCOL_PORT_DEFAULT;
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();
let tcpPort = this.port;
const tcpPortMax = tcpPort +
(PROTOCOL_PORT_MAX - PROTOCOL_PORT_MIN);
while (tcpPort <= tcpPortMax) {
try {
this._tcp.add_inet_port(tcpPort, null);
break;
} catch (e) {
if (tcpPort < tcpPortMax) {
tcpPort++;
continue;
}
throw e;
}
}
this._tcpPort = tcpPort;
this._tcp.connect('incoming', this._onIncomingChannel.bind(this));
} catch (e) {
this._tcp.stop();
this._tcp.close();
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._tcpPort;
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, true);
// Input stream
this._udp6_stream = new Gio.DataInputStream({
base_stream: new GioUnix.InputStream({
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, true);
// Input stream
this._udp4_stream = new Gio.DataInputStream({
base_stream: new GioUnix.InputStream({
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;
// 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 {
let data;
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 GLib.SOURCE_CONTINUE;
const 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 address = Gio.InetSocketAddress.new_from_string(
packet.body.tcpHost, packet.body.tcpPort);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address,
this.cancellable);
// 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._tcpPort;
}
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.
*/
export const 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 PROTOCOL_PORT_DEFAULT;
}
return this._port;
}
set port(port) {
this._port = port;
}
/**
* Authenticate a TLS connection.
*
* @param {Gio.TlsConnection} connection - A TLS connection
* @return {Promise} A promise for the operation
*/
async _authenticate(connection) {
// Standard TLS Handshake
connection.validation_flags = Gio.TlsCertificateFlags.EXPIRED;
connection.authentication_mode = Gio.TlsAuthenticationMode.REQUIRED;
await connection.handshake_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
// 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);
}
/**
* 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);
// 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,
});
const data = await stream.read_line_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
this.identity = new Core.Packet(data[0]);
if (!this.identity.body.deviceId)
throw new Error('missing deviceId');
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 connection.get_output_stream().write_all_async(
this.backend.identity.serialize(),
GLib.PRIORITY_DEFAULT,
this.cancellable);
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 address = Gio.InetSocketAddress.new_from_string(this.host,
packet.payloadTransferInfo.port);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address, cancellable)
.then(this._encryptClient.bind(this));
// Start the transfer
const transferredSize = await target.splice_async(
connection.input_stream,
(Gio.OutputStreamSpliceFlags.CLOSE_SOURCE |
Gio.OutputStreamSpliceFlags.CLOSE_TARGET),
GLib.PRIORITY_DEFAULT, 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 = listener.accept_async(cancellable)
.then(result => this._encryptServer(result[0]));
// Create an upload request
packet.body.payloadHash = this.checksum;
packet.payloadSize = size;
packet.payloadTransferInfo = {port: port};
const requestUpload = this.sendPacket(new Core.Packet(packet),
cancellable);
// Request an upload stream, accept the connection and get the output
const [, connection] = await Promise.all([requestUpload,
acceptConnection]);
// Start the transfer
const transferredSize = await connection.output_stream.splice_async(
source,
(Gio.OutputStreamSpliceFlags.CLOSE_SOURCE |
Gio.OutputStreamSpliceFlags.CLOSE_TARGET),
GLib.PRIORITY_DEFAULT, cancellable);
if (transferredSize !== size) {
throw new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.PARTIAL_INPUT,
message: 'Transfer incomplete',
});
}
}
async rejectTransfer(packet) {
try {
if (!packet || !packet.hasPayload())
return;
if (packet.payloadTransferInfo.port === undefined)
return;
const address = Gio.InetSocketAddress.new_from_string(this.host,
packet.payloadTransferInfo.port);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address, null)
.then(this._encryptClient.bind(this));
connection.close_async(GLib.PRIORITY_DEFAULT, null, null);
} catch (e) {
debug(e, this.device.name);
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,694 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import plugins from './plugins/index.js';
/**
* Get the local device type.
*
* @return {string} A device type string
*/
export function _getDeviceType() {
try {
let type = GLib.file_get_contents('/sys/class/dmi/id/chassis_type')[1];
type = Number(new TextDecoder().decode(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.
*/
export 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} data - Source data
*/
update(data) {
try {
if (typeof data === 'string')
Object.assign(this, JSON.parse(data));
else
Object.assign(this, data);
} 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.
*/
export const 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
*/
async 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,
});
}
const [data] = await this.input_stream.read_line_async(
GLib.PRIORITY_DEFAULT, cancellable);
if (data === null) {
throw new Gio.IOErrorEnum({
message: 'End of stream',
code: Gio.IOErrorEnum.CONNECTION_CLOSED,
});
}
return new Packet(data);
}
/**
* 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 this.output_stream.write_all_async(packet.serialize(),
GLib.PRIORITY_DEFAULT, cancellable);
}
/**
* 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.
*/
export const 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 cancellable() {
if (this._cancellable === undefined)
this._cancellable = new Gio.Cancellable();
return this._cancellable;
}
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 plugins) {
const meta = plugins[name].Metadata;
if (meta === undefined)
continue;
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.
*/
export const 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 item.file.replace_async(
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
GLib.PRIORITY_DEFAULT,
this._cancellable);
}
} else {
if (item.source instanceof Gio.InputStream)
return;
if (item.file instanceof Gio.File) {
const read = item.file.read_async(GLib.PRIORITY_DEFAULT,
cancellable);
const query = item.file.query_info_async(
Gio.FILE_ATTRIBUTE_STANDARD_SIZE,
Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT,
cancellable);
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();
}
});

View File

@@ -0,0 +1,702 @@
#!/usr/bin/env -S gjs -m
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk?version=3.0';
import 'gi://GdkPixbuf?version=2.0';
import Gio from 'gi://Gio?version=2.0';
import 'gi://GIRepository?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import GObject from 'gi://GObject?version=2.0';
import Gtk from 'gi://Gtk?version=3.0';
import 'gi://Pango?version=1.0';
import system from 'system';
import './init.js';
import Config from '../config.js';
import Manager from './manager.js';
import * as ServiceUI from './ui/service.js';
import('gi://GioUnix?version=2.0').catch(() => {}); // Set version for optional dependency
/**
* 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({
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
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(
'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 = new TextDecoder().decode(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('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;
}
}
});
await (new Service()).runAsync([system.programInvocationName].concat(ARGV));

View File

@@ -0,0 +1,428 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import {watchService} from '../wl_clipboard.js';
import Gio from 'gi://Gio';
import GIRepository from 'gi://GIRepository';
import GLib from 'gi://GLib';
import Config from '../config.js';
import setup, {setupGettext} from '../utils/setup.js';
// Promise Wrappers
// We don't use top-level await since it returns control flow to importing module, causing bugs
import('gi://EBook').then(({default: EBook}) => {
Gio._promisify(EBook.BookClient, 'connect');
Gio._promisify(EBook.BookClient.prototype, 'get_view');
Gio._promisify(EBook.BookClient.prototype, 'get_contacts');
}).catch(console.debug);
import('gi://EDataServer').then(({default: EDataServer}) => {
Gio._promisify(EDataServer.SourceRegistry, 'new');
}).catch(console.debug);
Gio._promisify(Gio.AsyncInitable.prototype, 'init_async');
Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Gio.DBusProxy.prototype, 'call');
Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async',
'read_line_finish_utf8');
Gio._promisify(Gio.File.prototype, 'delete_async');
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
Gio._promisify(Gio.File.prototype, 'mount_enclosing_volume');
Gio._promisify(Gio.File.prototype, 'query_info_async');
Gio._promisify(Gio.File.prototype, 'read_async');
Gio._promisify(Gio.File.prototype, 'replace_async');
Gio._promisify(Gio.File.prototype, 'replace_contents_bytes_async',
'replace_contents_finish');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.Mount.prototype, 'unmount_with_operation');
Gio._promisify(Gio.InputStream.prototype, 'close_async');
Gio._promisify(Gio.OutputStream.prototype, 'close_async');
Gio._promisify(Gio.OutputStream.prototype, 'splice_async');
Gio._promisify(Gio.OutputStream.prototype, 'write_all_async');
Gio._promisify(Gio.SocketClient.prototype, 'connect_async');
Gio._promisify(Gio.SocketListener.prototype, 'accept_async');
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
Gio._promisify(Gio.TlsConnection.prototype, 'handshake_async');
Gio._promisify(Gio.DtlsConnection.prototype, 'handshake_async');
// 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']);
// Bootstrap
const serviceFolder = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]);
const extensionFolder = GLib.path_get_dirname(serviceFolder);
setup(extensionFolder);
setupGettext();
if (Config.IS_USER) {
// 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;
}
// 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 = new TextDecoder().decode(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);
globalThis.HAVE_GNOME = GLib.getenv('GSCONNECT_MODE')?.toLowerCase() !== 'cli' && (GLib.getenv('GNOME_SETUP_DISPLAY') !== null || GLib.getenv('XDG_CURRENT_DESKTOP')?.toUpperCase()?.includes('GNOME') || GLib.getenv('XDG_SESSION_DESKTOP')?.toLowerCase() === 'gnome');
/**
* 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 = () => {};
/**
* Start wl_clipboard if not under Gnome
*/
if (!globalThis.HAVE_GNOME) {
debug('Not running as a Gnome extension');
watchService();
}
/**
* 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.length && b.length && (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, {
/**
* 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;
},
configurable: true,
enumerable: true,
},
/**
* Get just the pubkey as a DER ByteArray of a certificate.
*
* @return {GLib.Bytes} The pubkey as DER of the certificate.
*/
'pubkey_der': {
value: function () {
if (!this.__pubkey_der) {
let proc = new Gio.Subprocess({
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-pubkey', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
});
proc.init(null);
const pubkey = proc.communicate_utf8(this.certificate_pem, null)[1];
proc = new Gio.Subprocess({
argv: [Config.OPENSSL_PATH, 'pkey', '-pubin', '-inform', 'pem', '-outform', 'der'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
});
proc.init(null);
this.__pubkey_der = proc.communicate(new TextEncoder().encode(pubkey), null)[1];
}
return this.__pubkey_der;
},
configurable: true,
enumerable: false,
},
});

View File

@@ -0,0 +1,515 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import * as DBus from './utils/dbus.js';
import Device from './device.js';
import * as LanBackend from './backends/lan.js';
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);
const backends = {
lan: LanBackend,
};
/**
* A manager for devices.
*/
const 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 backends) {
try {
const module = backends[name];
if (module.ChannelService === undefined)
continue;
// Try to create the backend and track it if successful
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({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} 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(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);
}
});
export default Manager;

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env -S gjs -m
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import GObject from 'gi://GObject?version=2.0';
import system from 'system';
// Retain compatibility with GLib < 2.80, which lacks GioUnix
let GioUnix;
try {
GioUnix = (await import('gi://GioUnix?version=2.0')).default;
} catch (e) {
GioUnix = {
InputStream: Gio.UnixInputStream,
OutputStream: Gio.UnixOutputStream,
};
}
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 GioUnix.InputStream({fd: 0}),
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN,
});
this._stdout = new Gio.DataOutputStream({
base_stream: new GioUnix.OutputStream({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(new TextDecoder().decode(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
await (new NativeMessagingHost()).runAsync([system.programInvocationName]);

View File

@@ -0,0 +1,251 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import plugins from './plugins/index.js';
/**
* Base class for device plugins.
*/
const 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 = 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
const [contents] = await this._cacheFile.load_contents_async(
this.cancellable);
const cache = JSON.parse(new TextDecoder().decode(contents));
Object.assign(this, cache);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
} finally {
this.cacheLoaded();
}
}
/**
* An overridable function that is invoked when the on-disk cache is being
* cleared. Implementations should use this function to clear any in-memory
* cache data.
*/
clearCache() {}
/**
* An overridable function that is invoked when the cache is done loading
*/
cacheLoaded() {}
/**
* 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);
}
});
export default Plugin;

View File

@@ -0,0 +1,433 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const 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
*/
const BatteryPlugin = GObject.registerClass({
GTypeName: 'GSConnectBatteryPlugin',
}, class BatteryPlugin extends 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();
}
});
export default BatteryPlugin;

View File

@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const 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
*/
const ClipboardPlugin = GObject.registerClass({
GTypeName: 'GSConnectClipboardPlugin',
}, class ClipboardPlugin extends 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();
}
});
export default ClipboardPlugin;

View File

@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const 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
*/
const ConnectivityReportPlugin = GObject.registerClass({
GTypeName: 'GSConnectConnectivityReportPlugin',
}, class ConnectivityReportPlugin extends 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';
else if (this.network_type === '5G')
return 'network-cellular-5g-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();
}
});
export default ConnectivityReportPlugin;

View File

@@ -0,0 +1,463 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
import Contacts from '../components/contacts.js';
/*
* We prefer libebook's vCard parser if it's available
*/
let EBookContacts;
export const setEBookContacts = (ebook) => { // This function is only for tests to call!
EBookContacts = ebook;
};
try {
EBookContacts = (await import('gi://EBookContacts')).default;
} catch (e) {
EBookContacts = null;
}
export const 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
*/
const ContactsPlugin = GObject.registerClass({
GTypeName: 'GSConnectContactsPlugin',
}, class ContactsPlugin extends Plugin {
_init(device) {
super._init(device, 'contacts');
this._store = new Contacts(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();
}
});
export default ContactsPlugin;

View File

@@ -0,0 +1,249 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const 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
*/
const FindMyPhonePlugin = GObject.registerClass({
GTypeName: 'GSConnectFindMyPhonePlugin',
}, class FindMyPhonePlugin extends 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;
}
});
export default FindMyPhonePlugin;

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import * as battery from './battery.js';
import * as clipboard from './clipboard.js';
import * as connectivity_report from './connectivity_report.js';
import * as contacts from './contacts.js';
import * as findmyphone from './findmyphone.js';
import * as mousepad from './mousepad.js';
import * as mpris from './mpris.js';
import * as notification from './notification.js';
import * as ping from './ping.js';
import * as presenter from './presenter.js';
import * as runcommand from './runcommand.js';
import * as sftp from './sftp.js';
import * as share from './share.js';
import * as sms from './sms.js';
import * as systemvolume from './systemvolume.js';
import * as telephony from './telephony.js';
export default {
battery,
clipboard,
connectivity_report,
contacts,
findmyphone,
mousepad,
mpris,
notification,
ping,
presenter,
runcommand,
sftp,
share,
sms,
systemvolume,
telephony,
};

View File

@@ -0,0 +1,381 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import {InputDialog} from '../ui/mousepad.js';
import Plugin from '../plugin.js';
export const 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: _('Remote Input'),
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],
]);
const KeyMapCodes = new Map([
[1, 14],
[2, 15],
[3, 101],
[4, 105],
[5, 103],
[6, 106],
[7, 108],
[8, 104],
[9, 109],
[10, 102],
[11, 107],
[12, 28],
[13, 111],
[14, 1],
[15, 99],
[16, 70],
[17, 0],
[18, 0],
[19, 0],
[20, 0],
[21, 59],
[22, 60],
[23, 61],
[24, 62],
[25, 63],
[26, 64],
[27, 65],
[28, 66],
[29, 67],
[30, 68],
[31, 87],
[32, 88],
]);
/**
* Mousepad Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mousepad
*
* TODO: support outgoing mouse events?
*/
const MousepadPlugin = GObject.registerClass({
GTypeName: 'GSConnectMousepadPlugin',
Properties: {
'state': GObject.ParamSpec.boolean(
'state',
'State',
'Remote keyboard state',
GObject.ParamFlags.READABLE,
false
),
},
}, class MousepadPlugin extends Plugin {
_init(device) {
super._init(device, 'mousepad');
if (!globalThis.HAVE_GNOME)
this._input = Components.acquire('ydotool');
else
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;
const modifiers_codes = [];
// 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;
modifiers_codes.push(56);
}
if (input.ctrl) {
modifiers |= Gdk.ModifierType.CONTROL_MASK;
modifiers_codes.push(29);
}
if (input.shift) {
modifiers |= Gdk.ModifierType.SHIFT_MASK;
modifiers_codes.push(42);
}
if (input.super) {
modifiers |= Gdk.ModifierType.SUPER_MASK;
modifiers_codes.push(125);
}
// Regular key (printable ASCII or Unicode)
if (input.key) {
if (!globalThis.HAVE_GNOME)
this._input.pressKeys(input.key, modifiers_codes);
else
this._input.pressKeys(input.key, modifiers);
this._sendEcho(input);
// Special key (eg. non-printable ASCII)
} else if (input.specialKey && KeyMap.has(input.specialKey)) {
if (!globalThis.HAVE_GNOME) {
keysym = KeyMapCodes.get(input.specialKey);
this._input.pressKeys(keysym, modifiers_codes);
} else {
keysym = KeyMap.get(input.specialKey);
this._input.pressKeys(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.entry.buffer.text += input.key;
this._dialog._isAck = false;
} else if (KeyMap.get(input.specialKey) === Gdk.KEY_BackSpace) {
this._dialog.entry.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) {
if (!globalThis.HAVE_GNOME)
this._input = Components.release('ydotool');
else
this._input = Components.release('input');
}
if (this._dialog !== undefined)
this._dialog.destroy();
this.settings.disconnect(this._shareControlChangedId);
super.destroy();
}
});
export default MousepadPlugin;

View File

@@ -0,0 +1,917 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import * as DBus from '../utils/dbus.js';
import {Player} from '../components/mpris.js';
import Plugin from '../plugin.js';
export const 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
*/
const MPRISPlugin = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlugin',
}, class MPRISPlugin extends 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);
if (packet.body.hasOwnProperty('SetPosition')) {
// We want to avoid implementing this as a seek operation,
// because some players seek a fixed amount for every
// seek request, only respecting the sign of the parameter.
// (Chrome, for example, will only seek ±5 seconds, regardless
// what value is passed to Seek().)
const position = packet.body.SetPosition;
const metadata = player.Metadata;
if (metadata.hasOwnProperty('mpris:trackid')) {
const trackId = metadata['mpris:trackid'];
await player.SetPosition(trackId, position * 1000);
} else {
await player.Seek(position * 1000 - player.Position);
}
}
// 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 PlayerRemote = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlayerRemote',
}, class PlayerRemote extends 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();
const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2');
const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player');
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') ||
state.hasOwnProperty('artist') ||
state.hasOwnProperty('title'))
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;
}
}
}
});
export default MPRISPlugin;

View File

@@ -0,0 +1,694 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import Plugin from '../plugin.js';
import ReplyDialog from '../ui/notification.js';
export const 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
*/
const NotificationPlugin = GObject.registerClass({
GTypeName: 'GSConnectNotificationPlugin',
}, class NotificationPlugin extends 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 = file.read_async(GLib.PRIORITY_DEFAULT, null);
const query = file.query_info_async('standard::size',
Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null);
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 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();
}
});
export default NotificationPlugin;

View File

@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const 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
*/
const PingPlugin = GObject.registerClass({
GTypeName: 'GSConnectPingPlugin',
}, class PingPlugin extends 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);
}
});
export default PingPlugin;

View File

@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const 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/
*/
const PresenterPlugin = GObject.registerClass({
GTypeName: 'GSConnectPresenterPlugin',
}, class PresenterPlugin extends Plugin {
_init(device) {
super._init(device, 'presenter');
if (!globalThis.HAVE_GNOME)
this._input = Components.acquire('ydotool');
else
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) {
if (!globalThis.HAVE_GNOME)
this._input = Components.release('ydotool');
else
this._input = Components.release('input');
}
super.destroy();
}
});
export default PresenterPlugin;

View File

@@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const 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
*/
const RunCommandPlugin = 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 RunCommandPlugin extends 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('device.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();
}
});
export default RunCommandPlugin;

View File

@@ -0,0 +1,487 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
import Plugin from '../plugin.js';
export const 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
*/
const SFTPPlugin = GObject.registerClass({
GTypeName: 'GSConnectSFTPPlugin',
}, class SFTPPlugin extends 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.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
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 file.enumerate_children_async(
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
this.cancellable);
const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
GLib.PRIORITY_DEFAULT, this.cancellable);
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 file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
this.cancellable);
} 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))
return;
// There's a good chance this is a host key verification error;
// regardless we'll remove the key for security.
this._removeHostKey(this.device.channel.host);
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
*/
async _addPrivateKey() {
const ssh_add = this._launcher.spawnv([
Config.SSHADD_PATH,
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
]);
const [stdout] = await ssh_add.communicate_utf8_async(null,
this.cancellable);
if (ssh_add.get_exit_status() !== 0)
debug(stdout.trim(), this.device.name);
}
/**
* 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}`,
]);
const [stdout] = await ssh_keygen.communicate_utf8_async(null,
this.cancellable);
const status = ssh_keygen.get_exit_status();
if (status !== 0) {
throw new Gio.IOErrorEnum({
code: Gio.io_error_from_errno(status),
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
});
}
} 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 link.query_info_async(
'standard::symlink-target',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
this.cancellable);
if (link_stat.get_symlink_target() === link_target)
return;
await link.delete_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
throw e;
}
link.make_symbolic_link(link_target, this.cancellable);
} 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 this.gmount.unmount_with_operation(
Gio.MountUnmountFlags.FORCE,
new Gio.MountOperation(),
this.cancellable);
} 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();
}
});
export default SFTPPlugin;

View File

@@ -0,0 +1,492 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import Plugin from '../plugin.js';
import * as URI from '../utils/uri.js';
export const 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
*/
const SharePlugin = GObject.registerClass({
GTypeName: 'GSConnectSharePlugin',
}, class SharePlugin extends 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, action, 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
);
action = {
name: 'showPathInFolder',
parameter: new GLib.Variant('s', file.get_uri()),
};
buttons = [
{
label: _('Show File Location'),
action: 'showPathInFolder',
parameter: new GLib.Variant('s', file.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,
action: action,
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 */
const 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();
}
});
export default SharePlugin;

View File

@@ -0,0 +1,536 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
import LegacyMessagingDialog from '../ui/legacyMessaging.js';
import * as Messaging from '../ui/messaging.js';
import SmsURI from '../utils/uri.js';
export const 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
*/
export const MessageEventType = {
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
*/
export const MessageStatus = {
UNREAD: 0,
READ: 1,
};
/**
* SMS Message type, set from the 'type' field in the Android App
* message packet.
*
* See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
*
* ALL: all messages
* INBOX: Received messages
* SENT: Sent messages
* DRAFT: Message drafts
* OUTBOX: Outgoing messages
* FAILED: Failed outgoing messages
* QUEUED: Messages queued to send later
*/
export const 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/
*/
const SMSPlugin = 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 SMSPlugin extends 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 LegacyMessagingDialog({
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 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();
}
});
export default SMSPlugin;

View File

@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import Plugin from '../plugin.js';
export const 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/
*/
const SystemVolumePlugin = GObject.registerClass({
GTypeName: 'GSConnectSystemVolumePlugin',
}, class SystemVolumePlugin extends 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();
}
});
export default SystemVolumePlugin;

View File

@@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const 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
*/
const TelephonyPlugin = GObject.registerClass({
GTypeName: 'GSConnectTelephonyPlugin',
}, class TelephonyPlugin extends 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();
}
});
export default TelephonyPlugin;

View File

@@ -0,0 +1,642 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GdkPixbuf from 'gi://GdkPixbuf';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import system from 'system';
/**
* 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
*/
export 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();
export const 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)
*/
export const 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
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
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 {};
}
}
});

View File

@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Contacts from '../ui/contacts.js';
import * as Messaging from '../ui/messaging.js';
import * as URI from '../utils/uri.js';
import '../utils/ui.js';
const 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;
}
});
export default Dialog;

View File

@@ -0,0 +1,460 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import Gdk from 'gi://Gdk';
import GObject from 'gi://GObject';
import Gtk from '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);
export const 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',
'touchpad-eventbox', 'mouse-left-button', 'mouse-middle-button', 'mouse-right-button',
'touchpad-drag', 'touchpad-long-press',
'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 = _('Remote Input');
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);
// Mouse Pad
this._resetTouchpadMotion();
this.touchpad_motion_timeout_id = 0;
this.touchpad_holding = false;
// Scroll Input
this.add_events(Gdk.EventMask.SCROLL_MASK);
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_scroll_event(event) {
if (event.delta_x === 0 && event.delta_y === 0)
return true;
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
scroll: true,
dx: event.delta_x * 200,
dy: event.delta_y * 200,
},
});
return true;
}
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 = '';
}
_resetTouchpadMotion() {
this.touchpad_motion_prev_x = 0;
this.touchpad_motion_prev_y = 0;
this.touchpad_motion_x = 0;
this.touchpad_motion_y = 0;
}
_onMouseLeftButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singleclick: true,
},
});
}
_onMouseMiddleButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
middleclick: true,
},
});
}
_onMouseRightButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
rightclick: true,
},
});
}
_onTouchpadDragBegin(gesture) {
this._resetTouchpadMotion();
this.touchpad_motion_timeout_id =
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 10,
this._onTouchpadMotionTimeout.bind(this));
}
_onTouchpadDragUpdate(gesture, offset_x, offset_y) {
this.touchpad_motion_x = offset_x;
this.touchpad_motion_y = offset_y;
}
_onTouchpadDragEnd(gesture) {
this._resetTouchpadMotion();
GLib.Source.remove(this.touchpad_motion_timeout_id);
this.touchpad_motion_timeout_id = 0;
}
_onTouchpadLongPressCancelled(gesture) {
const gesture_button = gesture.get_current_button();
// Check user dragged less than certain distances.
const is_click =
(Math.abs(this.touchpad_motion_x) < 4) &&
(Math.abs(this.touchpad_motion_y) < 4);
if (is_click) {
const click_body = {};
switch (gesture_button) {
case 1:
click_body.singleclick = true;
break;
case 2:
click_body.middleclick = true;
break;
case 3:
click_body.rightclick = true;
break;
default:
return;
}
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: click_body,
});
}
}
_onTouchpadLongPressPressed(gesture) {
const gesture_button = gesture.get_current_button();
if (gesture_button !== 1) {
debug('Long press on other type of buttons are not handled.');
} else {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singlehold: true,
},
});
this.touchpad_holding = true;
}
}
_onTouchpadLongPressEnd(gesture) {
if (this.touchpad_holding) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singlerelease: true,
},
});
this.touchpad_holding = false;
}
}
_onTouchpadMotionTimeout() {
const diff_x = this.touchpad_motion_x - this.touchpad_motion_prev_x;
const diff_y = this.touchpad_motion_y - this.touchpad_motion_prev_y;
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
dx: diff_x,
dy: diff_y,
},
});
this.touchpad_motion_prev_x = this.touchpad_motion_x;
this.touchpad_motion_prev_y = this.touchpad_motion_y;
return true;
}
});

View File

@@ -0,0 +1,178 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as URI from '../utils/uri.js';
import '../utils/ui.js';
/**
* A dialog for repliable notifications.
*/
const 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);
}
});
export default ReplyDialog;

View File

@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import system from 'system';
import Config from '../../config.js';
/*
* Issue Header
*/
const ISSUE_HEADER = `
GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'})
GJS: ${system.version}
Session: ${GLib.getenv('XDG_SESSION_TYPE')}
OS: ${GLib.get_os_info('PRETTY_NAME')}
`;
/**
* A dialog for selecting a device
*/
export const 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.
*/
export const 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;
}
});

View File

@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GjsPrivate from 'gi://GjsPrivate';
import GLib from 'gi://GLib';
import GObject from '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 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.
*/
export const 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 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
*/
export 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);
}
}
);
});
}

View File

@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import Config from '../../config.js';
/*
* 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));
};

View File

@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from '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
*/
export 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
*/
export 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)
*/
export default 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;
}
}