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,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;