mirror of
https://gitlab.com/thebiblelover7/dotfiles.git
synced 2025-09-14 07:33:49 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Battery'),
|
||||
description: _('Exchange battery information'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Battery',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.battery',
|
||||
'kdeconnect.battery.request',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.battery',
|
||||
'kdeconnect.battery.request',
|
||||
],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Battery Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/battery
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectBatteryPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'battery');
|
||||
|
||||
// Setup Cache; defaults are 90 minute charge, 1 day discharge
|
||||
this._chargeState = [54, 0, -1];
|
||||
this._dischargeState = [864, 0, -1];
|
||||
this._thresholdLevel = 25;
|
||||
|
||||
this.cacheProperties([
|
||||
'_chargeState',
|
||||
'_dischargeState',
|
||||
'_thresholdLevel',
|
||||
]);
|
||||
|
||||
// Export battery state as GAction
|
||||
this.__state = new Gio.SimpleAction({
|
||||
name: 'battery',
|
||||
parameter_type: new GLib.VariantType('(bsii)'),
|
||||
state: this.state,
|
||||
});
|
||||
this.device.add_action(this.__state);
|
||||
|
||||
// Local Battery (UPower)
|
||||
this._upower = null;
|
||||
this._sendStatisticsId = this.settings.connect(
|
||||
'changed::send-statistics',
|
||||
this._onSendStatisticsChanged.bind(this)
|
||||
);
|
||||
this._onSendStatisticsChanged(this.settings);
|
||||
}
|
||||
|
||||
get charging() {
|
||||
if (this._charging === undefined)
|
||||
this._charging = false;
|
||||
|
||||
return this._charging;
|
||||
}
|
||||
|
||||
get icon_name() {
|
||||
let icon;
|
||||
|
||||
if (this.level === -1)
|
||||
return 'battery-missing-symbolic';
|
||||
else if (this.level === 100)
|
||||
return 'battery-full-charged-symbolic';
|
||||
else if (this.level < 3)
|
||||
icon = 'battery-empty';
|
||||
else if (this.level < 10)
|
||||
icon = 'battery-caution';
|
||||
else if (this.level < 30)
|
||||
icon = 'battery-low';
|
||||
else if (this.level < 60)
|
||||
icon = 'battery-good';
|
||||
else if (this.level >= 60)
|
||||
icon = 'battery-full';
|
||||
|
||||
if (this.charging)
|
||||
return `${icon}-charging-symbolic`;
|
||||
|
||||
return `${icon}-symbolic`;
|
||||
}
|
||||
|
||||
get level() {
|
||||
// This is what KDE Connect returns if the remote battery plugin is
|
||||
// disabled or still being loaded
|
||||
if (this._level === undefined)
|
||||
this._level = -1;
|
||||
|
||||
return this._level;
|
||||
}
|
||||
|
||||
get time() {
|
||||
if (this._time === undefined)
|
||||
this._time = 0;
|
||||
|
||||
return this._time;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return new GLib.Variant(
|
||||
'(bsii)',
|
||||
[this.charging, this.icon_name, this.level, this.time]
|
||||
);
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
this._initEstimate();
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._chargeState = [54, 0, -1];
|
||||
this._dischargeState = [864, 0, -1];
|
||||
this._thresholdLevel = 25;
|
||||
this._initEstimate();
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestState();
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.battery':
|
||||
this._receiveState(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.battery.request':
|
||||
this._sendState();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onSendStatisticsChanged() {
|
||||
if (this.settings.get_boolean('send-statistics'))
|
||||
this._monitorState();
|
||||
else
|
||||
this._unmonitorState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate and update the estimated time remaining, but not the rate.
|
||||
*/
|
||||
_initEstimate() {
|
||||
let rate, level;
|
||||
|
||||
// elision of [rate, time, level]
|
||||
if (this.charging)
|
||||
[rate,, level] = this._chargeState;
|
||||
else
|
||||
[rate,, level] = this._dischargeState;
|
||||
|
||||
if (!Number.isFinite(rate) || rate < 1)
|
||||
rate = this.charging ? 864 : 90;
|
||||
|
||||
if (!Number.isFinite(level) || level < 0)
|
||||
level = this.level;
|
||||
|
||||
// Update the time remaining
|
||||
if (rate && this.charging)
|
||||
this._time = Math.floor(rate * (100 - level));
|
||||
else if (rate && !this.charging)
|
||||
this._time = Math.floor(rate * level);
|
||||
|
||||
this.__state.state = this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the (dis)charge rate and update the estimated time remaining.
|
||||
*/
|
||||
_updateEstimate() {
|
||||
let rate, time, level;
|
||||
const newTime = Math.floor(Date.now() / 1000);
|
||||
const newLevel = this.level;
|
||||
|
||||
// Load the state; ensure we have sane values for calculation
|
||||
if (this.charging)
|
||||
[rate, time, level] = this._chargeState;
|
||||
else
|
||||
[rate, time, level] = this._dischargeState;
|
||||
|
||||
if (!Number.isFinite(rate) || rate < 1)
|
||||
rate = this.charging ? 54 : 864;
|
||||
|
||||
if (!Number.isFinite(time) || time <= 0)
|
||||
time = newTime;
|
||||
|
||||
if (!Number.isFinite(level) || level < 0)
|
||||
level = newLevel;
|
||||
|
||||
// Update the rate; use a weighted average to account for missed changes
|
||||
// NOTE: (rate = seconds/percent)
|
||||
const ldiff = this.charging ? newLevel - level : level - newLevel;
|
||||
const tdiff = newTime - time;
|
||||
const newRate = tdiff / ldiff;
|
||||
|
||||
if (newRate && Number.isFinite(newRate))
|
||||
rate = Math.floor((rate * 0.4) + (newRate * 0.6));
|
||||
|
||||
// Store the state for the next recalculation
|
||||
if (this.charging)
|
||||
this._chargeState = [rate, newTime, newLevel];
|
||||
else
|
||||
this._dischargeState = [rate, newTime, newLevel];
|
||||
|
||||
// Update the time remaining
|
||||
if (rate && this.charging)
|
||||
this._time = Math.floor(rate * (100 - newLevel));
|
||||
else if (rate && !this.charging)
|
||||
this._time = Math.floor(rate * newLevel);
|
||||
|
||||
this.__state.state = this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is full.
|
||||
*/
|
||||
_fullBatteryNotification() {
|
||||
if (!this.settings.get_boolean('full-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|full',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery is full
|
||||
title: _('%s: Battery is full').format(this.device.name),
|
||||
// TRANSLATORS: when the battery is fully charged
|
||||
body: _('Fully Charged'),
|
||||
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is at custom charge level.
|
||||
*/
|
||||
_customBatteryNotification() {
|
||||
if (!this.settings.get_boolean('custom-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|custom',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery has reached custom charge level
|
||||
title: _('%s: Battery has reached custom charge level').format(this.device.name),
|
||||
// TRANSLATORS: when the battery has reached custom charge level
|
||||
body: _('%d%% Charged').format(this.level),
|
||||
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user the remote battery is low.
|
||||
*/
|
||||
_lowBatteryNotification() {
|
||||
if (!this.settings.get_boolean('low-battery-notification'))
|
||||
return;
|
||||
|
||||
// Offer the option to ring the device, if available
|
||||
let buttons = [];
|
||||
|
||||
if (this.device.get_action_enabled('ring')) {
|
||||
buttons = [{
|
||||
label: _('Ring'),
|
||||
action: 'ring',
|
||||
parameter: null,
|
||||
}];
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: 'battery|low',
|
||||
// TRANSLATORS: eg. Google Pixel: Battery is low
|
||||
title: _('%s: Battery is low').format(this.device.name),
|
||||
// TRANSLATORS: eg. 15% remaining
|
||||
body: _('%d%% remaining').format(this.level),
|
||||
icon: Gio.ThemedIcon.new('battery-caution-symbolic'),
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote battery update.
|
||||
*
|
||||
* @param {Core.Packet} packet - A kdeconnect.battery packet
|
||||
*/
|
||||
_receiveState(packet) {
|
||||
// Charging state changed
|
||||
this._charging = packet.body.isCharging;
|
||||
|
||||
// Level changed
|
||||
if (this._level !== packet.body.currentCharge) {
|
||||
this._level = packet.body.currentCharge;
|
||||
|
||||
// If the level is above the threshold hide the notification
|
||||
if (this._level > this._thresholdLevel)
|
||||
this.device.hideNotification('battery|low');
|
||||
|
||||
// The level just changed to/from custom level while charging
|
||||
if ((this._level === this.settings.get_uint('custom-battery-notification-value')) && this._charging)
|
||||
this._customBatteryNotification();
|
||||
else
|
||||
this.device.hideNotification('battery|custom');
|
||||
|
||||
// The level just changed to/from full
|
||||
if (this._level === 100)
|
||||
this._fullBatteryNotification();
|
||||
else
|
||||
this.device.hideNotification('battery|full');
|
||||
}
|
||||
|
||||
// Device considers the level low
|
||||
if (packet.body.thresholdEvent > 0) {
|
||||
this._lowBatteryNotification();
|
||||
this._thresholdLevel = this.level;
|
||||
}
|
||||
|
||||
this._updateEstimate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote battery's current state
|
||||
*/
|
||||
_requestState() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.battery.request',
|
||||
body: {request: true},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the local battery's current state
|
||||
*/
|
||||
_sendState() {
|
||||
if (this._upower === null || !this._upower.is_present)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.battery',
|
||||
body: {
|
||||
currentCharge: this._upower.level,
|
||||
isCharging: this._upower.charging,
|
||||
thresholdEvent: this._upower.threshold,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* UPower monitoring methods
|
||||
*/
|
||||
_monitorState() {
|
||||
try {
|
||||
// Currently only true if the remote device is a desktop (rare)
|
||||
const incoming = this.device.settings.get_strv('incoming-capabilities');
|
||||
|
||||
if (!incoming.includes('kdeconnect.battery'))
|
||||
return;
|
||||
|
||||
this._upower = Components.acquire('upower');
|
||||
|
||||
this._upowerId = this._upower.connect(
|
||||
'changed',
|
||||
this._sendState.bind(this)
|
||||
);
|
||||
|
||||
this._sendState();
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
this._unmonitorState();
|
||||
}
|
||||
}
|
||||
|
||||
_unmonitorState() {
|
||||
try {
|
||||
if (this._upower === null)
|
||||
return;
|
||||
|
||||
this._upower.disconnect(this._upowerId);
|
||||
this._upower = Components.release('upower');
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.device.remove_action('battery');
|
||||
this.settings.disconnect(this._sendStatisticsId);
|
||||
this._unmonitorState();
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,178 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Clipboard'),
|
||||
description: _('Share the clipboard content'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Clipboard',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.clipboard',
|
||||
'kdeconnect.clipboard.connect',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.clipboard',
|
||||
'kdeconnect.clipboard.connect',
|
||||
],
|
||||
actions: {
|
||||
clipboardPush: {
|
||||
label: _('Clipboard Push'),
|
||||
icon_name: 'edit-paste-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.clipboard'],
|
||||
},
|
||||
clipboardPull: {
|
||||
label: _('Clipboard Pull'),
|
||||
icon_name: 'edit-copy-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: ['kdeconnect.clipboard'],
|
||||
outgoing: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Clipboard Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/clipboard
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectClipboardPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'clipboard');
|
||||
|
||||
this._clipboard = Components.acquire('clipboard');
|
||||
|
||||
// Watch local clipboard for changes
|
||||
this._textChangedId = this._clipboard.connect(
|
||||
'notify::text',
|
||||
this._onLocalClipboardChanged.bind(this)
|
||||
);
|
||||
|
||||
// Buffer content to allow selective sync
|
||||
this._localBuffer = this._clipboard.text;
|
||||
this._localTimestamp = 0;
|
||||
this._remoteBuffer = null;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
// TODO: if we're not auto-syncing local->remote, but we are doing the
|
||||
// reverse, it's possible older remote content will end up
|
||||
// overwriting newer local content.
|
||||
if (!this.settings.get_boolean('send-content'))
|
||||
return;
|
||||
|
||||
if (this._localBuffer === null && this._localTimestamp === 0)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.clipboard.connect',
|
||||
body: {
|
||||
content: this._localBuffer,
|
||||
timestamp: this._localTimestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
if (!packet.body.hasOwnProperty('content'))
|
||||
return;
|
||||
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.clipboard':
|
||||
this._handleContent(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.clipboard.connect':
|
||||
this._handleConnectContent(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_handleContent(packet) {
|
||||
this._onRemoteClipboardChanged(packet.body.content);
|
||||
}
|
||||
|
||||
_handleConnectContent(packet) {
|
||||
if (packet.body.hasOwnProperty('timestamp') &&
|
||||
packet.body.timestamp > this._localTimestamp)
|
||||
this._onRemoteClipboardChanged(packet.body.content);
|
||||
}
|
||||
|
||||
/*
|
||||
* Store the local clipboard content and forward it if enabled
|
||||
*/
|
||||
_onLocalClipboardChanged(clipboard, pspec) {
|
||||
this._localBuffer = clipboard.text;
|
||||
this._localTimestamp = Date.now();
|
||||
|
||||
if (this.settings.get_boolean('send-content'))
|
||||
this.clipboardPush();
|
||||
}
|
||||
|
||||
/*
|
||||
* Store the remote clipboard content and apply it if enabled
|
||||
*/
|
||||
_onRemoteClipboardChanged(text) {
|
||||
this._remoteBuffer = text;
|
||||
|
||||
if (this.settings.get_boolean('receive-content'))
|
||||
this.clipboardPull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to the remote clipboard; called by _onLocalClipboardChanged()
|
||||
*/
|
||||
clipboardPush() {
|
||||
// Don't sync if the clipboard is empty or not text
|
||||
if (this._localTimestamp === 0)
|
||||
return;
|
||||
|
||||
if (this._remoteBuffer !== this._localBuffer) {
|
||||
this._remoteBuffer = this._localBuffer;
|
||||
|
||||
// If the buffer is %null, the clipboard contains non-text content,
|
||||
// so we neither clear the remote clipboard nor pass the content
|
||||
if (this._localBuffer !== null) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.clipboard',
|
||||
body: {
|
||||
content: this._localBuffer,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy from the remote clipboard; called by _onRemoteClipboardChanged()
|
||||
*/
|
||||
clipboardPull() {
|
||||
if (this._localBuffer !== this._remoteBuffer) {
|
||||
this._localBuffer = this._remoteBuffer;
|
||||
this._localTimestamp = Date.now();
|
||||
|
||||
this._clipboard.text = this._remoteBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._clipboard && this._textChangedId) {
|
||||
this._clipboard.disconnect(this._textChangedId);
|
||||
this._clipboard = Components.release('clipboard');
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,162 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Connectivity Report'),
|
||||
description: _('Display connectivity status'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.ConnectivityReport',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.connectivity_report',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.connectivity_report.request',
|
||||
],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Connectivity Report Plugin
|
||||
* https://invent.kde.org/network/kdeconnect-kde/-/tree/master/plugins/connectivity_report
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectConnectivityReportPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'connectivity_report');
|
||||
|
||||
// Export connectivity state as GAction
|
||||
this.__state = new Gio.SimpleAction({
|
||||
name: 'connectivityReport',
|
||||
// (
|
||||
// cellular_network_type,
|
||||
// cellular_network_type_icon,
|
||||
// cellular_network_strength(0..4),
|
||||
// cellular_network_strength_icon,
|
||||
// )
|
||||
parameter_type: new GLib.VariantType('(ssis)'),
|
||||
state: this.state,
|
||||
});
|
||||
this.device.add_action(this.__state);
|
||||
}
|
||||
|
||||
get signal_strength() {
|
||||
if (this._signalStrength === undefined)
|
||||
this._signalStrength = -1;
|
||||
|
||||
return this._signalStrength;
|
||||
}
|
||||
|
||||
get network_type() {
|
||||
if (this._networkType === undefined)
|
||||
this._networkType = '';
|
||||
|
||||
return this._networkType;
|
||||
}
|
||||
|
||||
get signal_strength_icon_name() {
|
||||
if (this.signal_strength === 0)
|
||||
return 'network-cellular-signal-none-symbolic'; // SIGNAL_STRENGTH_NONE_OR_UNKNOWN
|
||||
else if (this.signal_strength === 1)
|
||||
return 'network-cellular-signal-weak-symbolic'; // SIGNAL_STRENGTH_POOR
|
||||
else if (this.signal_strength === 2)
|
||||
return 'network-cellular-signal-ok-symbolic'; // SIGNAL_STRENGTH_MODERATE
|
||||
else if (this.signal_strength === 3)
|
||||
return 'network-cellular-signal-good-symbolic'; // SIGNAL_STRENGTH_GOOD
|
||||
else if (this.signal_strength >= 4)
|
||||
return 'network-cellular-signal-excellent-symbolic'; // SIGNAL_STRENGTH_GREAT
|
||||
|
||||
return 'network-cellular-offline-symbolic'; // OFF (signal_strength == -1)
|
||||
}
|
||||
|
||||
get network_type_icon_name() {
|
||||
if (this.network_type === 'GSM' || this.network_type === 'CDMA' || this.network_type === 'iDEN')
|
||||
return 'network-cellular-2g-symbolic';
|
||||
else if (this.network_type === 'UMTS' || this.network_type === 'CDMA2000')
|
||||
return 'network-cellular-3g-symbolic';
|
||||
else if (this.network_type === 'LTE')
|
||||
return 'network-cellular-4g-symbolic';
|
||||
else if (this.network_type === 'EDGE')
|
||||
return 'network-cellular-edge-symbolic';
|
||||
else if (this.network_type === 'GPRS')
|
||||
return 'network-cellular-gprs-symbolic';
|
||||
else if (this.network_type === 'HSPA')
|
||||
return 'network-cellular-hspa-symbolic';
|
||||
// FIXME: No icon for this!
|
||||
// https://gitlab.gnome.org/GNOME/adwaita-icon-theme/-/issues/114
|
||||
else if (this.network_type === '5G')
|
||||
return 'network-cellular-symbolic';
|
||||
|
||||
return 'network-cellular-symbolic';
|
||||
}
|
||||
|
||||
get state() {
|
||||
return new GLib.Variant(
|
||||
'(ssis)',
|
||||
[
|
||||
this.network_type,
|
||||
this.network_type_icon_name,
|
||||
this.signal_strength,
|
||||
this.signal_strength_icon_name,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestState();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.connectivity_report':
|
||||
this._receiveState(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote state update.
|
||||
*
|
||||
* @param {Core.Packet} packet - A kdeconnect.connectivity_report packet
|
||||
*/
|
||||
_receiveState(packet) {
|
||||
if (packet.body.signalStrengths) {
|
||||
// TODO: Only first SIM (subscriptionID) is supported at the moment
|
||||
const subs = Object.keys(packet.body.signalStrengths);
|
||||
const firstSub = Math.min.apply(null, subs);
|
||||
const data = packet.body.signalStrengths[firstSub];
|
||||
|
||||
this._networkType = data.networkType;
|
||||
this._signalStrength = data.signalStrength;
|
||||
}
|
||||
|
||||
// Update DBus state
|
||||
this.__state.state = this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote device's connectivity state
|
||||
*/
|
||||
_requestState() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.connectivity_report.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.device.remove_action('connectivity_report');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,456 @@
|
||||
'use strict';
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
const Contacts = imports.service.components.contacts;
|
||||
|
||||
/*
|
||||
* We prefer libebook's vCard parser if it's available
|
||||
*/
|
||||
var EBookContacts;
|
||||
|
||||
try {
|
||||
EBookContacts = imports.gi.EBookContacts;
|
||||
} catch (e) {
|
||||
EBookContacts = null;
|
||||
}
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Contacts'),
|
||||
description: _('Access contacts of the paired device'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.contacts.response_uids_timestamps',
|
||||
'kdeconnect.contacts.response_vcards',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.contacts.request_all_uids_timestamps',
|
||||
'kdeconnect.contacts.request_vcards_by_uid',
|
||||
],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* vCard 2.1 Patterns
|
||||
*/
|
||||
const VCARD_FOLDING = /\r\n |\r |\n |=\n/g;
|
||||
const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i;
|
||||
const VCARD_BASIC = /^([^:;]+):(.+)$/;
|
||||
const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/;
|
||||
const VCARD_TYPED_KEY = /item\d{1,2}\./;
|
||||
const VCARD_TYPED_META = /([a-z]+)=(.*)/i;
|
||||
|
||||
|
||||
/**
|
||||
* Contacts Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectContactsPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'contacts');
|
||||
|
||||
this._store = new Contacts.Store(device.id);
|
||||
this._store.fetch = this._requestUids.bind(this);
|
||||
|
||||
// Notify when the store is ready
|
||||
this._contactsStoreReadyId = this._store.connect(
|
||||
'notify::context',
|
||||
() => this.device.notify('contacts')
|
||||
);
|
||||
|
||||
// Notify if the contacts source changes
|
||||
this._contactsSourceChangedId = this.settings.connect(
|
||||
'changed::contacts-source',
|
||||
() => this.device.notify('contacts')
|
||||
);
|
||||
|
||||
// Load the cache
|
||||
this._store.load();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._store.clear();
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
this._requestUids();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.contacts.response_uids_timestamps':
|
||||
this._handleUids(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.contacts.response_vcards':
|
||||
this._handleVCards(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_handleUids(packet) {
|
||||
try {
|
||||
const contacts = this._store.contacts;
|
||||
const remote_uids = packet.body.uids;
|
||||
let removed = false;
|
||||
delete packet.body.uids;
|
||||
|
||||
// Usually a failed request, so avoid wiping the cache
|
||||
if (remote_uids.length === 0)
|
||||
return;
|
||||
|
||||
// Delete any contacts that were removed on the device
|
||||
for (let i = 0, len = contacts.length; i < len; i++) {
|
||||
const contact = contacts[i];
|
||||
|
||||
if (!remote_uids.includes(contact.id)) {
|
||||
this._store.remove(contact.id, false);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a list of new or updated contacts
|
||||
const uids = [];
|
||||
|
||||
for (const [uid, timestamp] of Object.entries(packet.body)) {
|
||||
const contact = this._store.get_contact(uid);
|
||||
|
||||
if (!contact || contact.timestamp !== timestamp)
|
||||
uids.push(uid);
|
||||
}
|
||||
|
||||
// Send a request for any new or updated contacts
|
||||
if (uids.length)
|
||||
this._requestVCards(uids);
|
||||
|
||||
// If we removed any contacts, save the cache
|
||||
if (removed)
|
||||
this._store.save();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string
|
||||
*
|
||||
* See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js
|
||||
*
|
||||
* @param {string} input - The QUOTED-PRINTABLE string
|
||||
* @return {string} The decoded string
|
||||
*/
|
||||
_decodeQuotedPrintable(input) {
|
||||
return input
|
||||
// https://tools.ietf.org/html/rfc2045#section-6.7, rule 3
|
||||
.replace(/[\t\x20]$/gm, '')
|
||||
// Remove hard line breaks preceded by `=`
|
||||
.replace(/=(?:\r\n?|\n|$)/g, '')
|
||||
// https://tools.ietf.org/html/rfc2045#section-6.7, note 1.
|
||||
.replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => {
|
||||
const codePoint = parseInt($1, 16);
|
||||
return String.fromCharCode(codePoint);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a string encoded as "UTF-8" and return a regular string
|
||||
*
|
||||
* See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js
|
||||
*
|
||||
* @param {string} input - The UTF-8 string
|
||||
* @return {string} The decoded string
|
||||
*/
|
||||
_decodeUTF8(input) {
|
||||
try {
|
||||
const output = [];
|
||||
let i = 0;
|
||||
let c1 = 0;
|
||||
let seqlen = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
c1 = input.charCodeAt(i) & 0xFF;
|
||||
seqlen = 0;
|
||||
|
||||
if (c1 <= 0xBF) {
|
||||
c1 &= 0x7F;
|
||||
seqlen = 1;
|
||||
} else if (c1 <= 0xDF) {
|
||||
c1 &= 0x1F;
|
||||
seqlen = 2;
|
||||
} else if (c1 <= 0xEF) {
|
||||
c1 &= 0x0F;
|
||||
seqlen = 3;
|
||||
} else {
|
||||
c1 &= 0x07;
|
||||
seqlen = 4;
|
||||
}
|
||||
|
||||
for (let ai = 1; ai < seqlen; ++ai)
|
||||
c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F));
|
||||
|
||||
if (seqlen === 4) {
|
||||
c1 -= 0x10000;
|
||||
output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)));
|
||||
output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)));
|
||||
} else {
|
||||
output.push(String.fromCharCode(c1));
|
||||
}
|
||||
|
||||
i += seqlen;
|
||||
}
|
||||
|
||||
return output.join('');
|
||||
|
||||
// Fallback to old unfaithful
|
||||
} catch (e) {
|
||||
try {
|
||||
return decodeURIComponent(escape(input));
|
||||
|
||||
// Say "chowdah" frenchie!
|
||||
} catch (e) {
|
||||
debug(e, `Failed to decode UTF-8 VCard field ${input}`);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vCard (v2.1 only) and return a dictionary of the fields
|
||||
*
|
||||
* See: http://jsfiddle.net/ARTsinn/P2t2P/
|
||||
*
|
||||
* @param {string} vcard_data - The raw VCard data
|
||||
* @return {Object} dictionary of vCard data
|
||||
*/
|
||||
_parseVCard21(vcard_data) {
|
||||
// vcard skeleton
|
||||
const vcard = {
|
||||
fn: _('Unknown Contact'),
|
||||
tel: [],
|
||||
};
|
||||
|
||||
// Remove line folding and split
|
||||
const unfolded = vcard_data.replace(VCARD_FOLDING, '');
|
||||
const lines = unfolded.split(/\r\n|\r|\n/);
|
||||
|
||||
for (let i = 0, len = lines.length; i < len; i++) {
|
||||
const line = lines[i];
|
||||
let results, key, type, value;
|
||||
|
||||
// Empty line or a property we aren't interested in
|
||||
if (!line || !line.match(VCARD_SUPPORTED))
|
||||
continue;
|
||||
|
||||
// Basic Fields (fn, x-kdeconnect-timestamp, etc)
|
||||
if ((results = line.match(VCARD_BASIC))) {
|
||||
[, key, value] = results;
|
||||
vcard[key.toLowerCase()] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Typed Fields (tel, adr, etc)
|
||||
if ((results = line.match(VCARD_TYPED))) {
|
||||
[, key, type, value] = results;
|
||||
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
|
||||
value = value.split(';');
|
||||
type = type.split(';');
|
||||
|
||||
// Type(s)
|
||||
const meta = {};
|
||||
|
||||
for (let i = 0, len = type.length; i < len; i++) {
|
||||
const res = type[i].match(VCARD_TYPED_META);
|
||||
|
||||
if (res)
|
||||
meta[res[1]] = res[2];
|
||||
else
|
||||
meta[`type${i === 0 ? '' : i}`] = type[i].toLowerCase();
|
||||
}
|
||||
|
||||
// Value(s)
|
||||
if (vcard[key] === undefined)
|
||||
vcard[key] = [];
|
||||
|
||||
// Decode QUOTABLE-PRINTABLE
|
||||
if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') {
|
||||
delete meta.ENCODING;
|
||||
value = value.map(v => this._decodeQuotedPrintable(v));
|
||||
}
|
||||
|
||||
// Decode UTF-8
|
||||
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
|
||||
delete meta.CHARSET;
|
||||
value = value.map(v => this._decodeUTF8(v));
|
||||
}
|
||||
|
||||
// Special case for FN (full name)
|
||||
if (key === 'fn')
|
||||
vcard[key] = value[0];
|
||||
else
|
||||
vcard[key].push({meta: meta, value: value});
|
||||
}
|
||||
}
|
||||
|
||||
return vcard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vCard (v2.1 only) using native JavaScript and add it to the
|
||||
* contact store.
|
||||
*
|
||||
* @param {string} uid - The contact UID
|
||||
* @param {string} vcard_data - The raw vCard data
|
||||
*/
|
||||
async _parseVCardNative(uid, vcard_data) {
|
||||
try {
|
||||
const vcard = this._parseVCard21(vcard_data);
|
||||
|
||||
const contact = {
|
||||
id: uid,
|
||||
name: vcard.fn,
|
||||
numbers: [],
|
||||
origin: 'device',
|
||||
timestamp: parseInt(vcard['x-kdeconnect-timestamp']),
|
||||
};
|
||||
|
||||
// Phone Numbers
|
||||
contact.numbers = vcard.tel.map(entry => {
|
||||
let type = 'unknown';
|
||||
|
||||
if (entry.meta && entry.meta.type)
|
||||
type = entry.meta.type;
|
||||
|
||||
return {type: type, value: entry.value[0]};
|
||||
});
|
||||
|
||||
// Avatar
|
||||
if (vcard.photo) {
|
||||
const data = GLib.base64_decode(vcard.photo[0].value[0]);
|
||||
contact.avatar = await this._store.storeAvatar(data);
|
||||
}
|
||||
|
||||
this._store.add(contact);
|
||||
} catch (e) {
|
||||
debug(e, `Failed to parse VCard contact ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a vCard using libebook and add it to the contact store.
|
||||
*
|
||||
* @param {string} uid - The contact UID
|
||||
* @param {string} vcard_data - The raw vCard data
|
||||
*/
|
||||
async _parseVCard(uid, vcard_data) {
|
||||
try {
|
||||
const contact = {
|
||||
id: uid,
|
||||
name: _('Unknown Contact'),
|
||||
numbers: [],
|
||||
origin: 'device',
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const evcard = EBookContacts.VCard.new_from_string(vcard_data);
|
||||
const attrs = evcard.get_attributes();
|
||||
|
||||
for (let i = 0, len = attrs.length; i < len; i++) {
|
||||
const attr = attrs[i];
|
||||
let data, number;
|
||||
|
||||
switch (attr.get_name().toLowerCase()) {
|
||||
case 'fn':
|
||||
contact.name = attr.get_value();
|
||||
break;
|
||||
|
||||
case 'tel':
|
||||
number = {value: attr.get_value(), type: 'unknown'};
|
||||
|
||||
if (attr.has_type('CELL'))
|
||||
number.type = 'cell';
|
||||
else if (attr.has_type('HOME'))
|
||||
number.type = 'home';
|
||||
else if (attr.has_type('WORK'))
|
||||
number.type = 'work';
|
||||
|
||||
contact.numbers.push(number);
|
||||
break;
|
||||
|
||||
case 'x-kdeconnect-timestamp':
|
||||
contact.timestamp = parseInt(attr.get_value());
|
||||
break;
|
||||
|
||||
case 'photo':
|
||||
data = GLib.base64_decode(attr.get_value());
|
||||
contact.avatar = await this._store.storeAvatar(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this._store.add(contact);
|
||||
} catch (e) {
|
||||
debug(e, `Failed to parse VCard contact ${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming list of contact vCards and pass them to the best
|
||||
* available parser.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards`
|
||||
*/
|
||||
_handleVCards(packet) {
|
||||
try {
|
||||
// We don't use this
|
||||
delete packet.body.uids;
|
||||
|
||||
// Parse each vCard and add the contact
|
||||
for (const [uid, vcard] of Object.entries(packet.body)) {
|
||||
if (EBookContacts)
|
||||
this._parseVCard(uid, vcard);
|
||||
else
|
||||
this._parseVCardNative(uid, vcard);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of contact UIDs with timestamps.
|
||||
*/
|
||||
_requestUids() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.contacts.request_all_uids_timestamps',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the vCards for @uids.
|
||||
*
|
||||
* @param {string[]} uids - A list of contact UIDs
|
||||
*/
|
||||
_requestVCards(uids) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.contacts.request_vcards_by_uid',
|
||||
body: {
|
||||
uids: uids,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._store.disconnect(this._contactsStoreReadyId);
|
||||
this.settings.disconnect(this._contactsSourceChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Find My Phone'),
|
||||
description: _('Ring your paired device'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.FindMyPhone',
|
||||
incomingCapabilities: ['kdeconnect.findmyphone.request'],
|
||||
outgoingCapabilities: ['kdeconnect.findmyphone.request'],
|
||||
actions: {
|
||||
ring: {
|
||||
label: _('Ring'),
|
||||
icon_name: 'phonelink-ring-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.findmyphone.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* FindMyPhone Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/findmyphone
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectFindMyPhonePlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'findmyphone');
|
||||
|
||||
this._dialog = null;
|
||||
this._player = Components.acquire('sound');
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.findmyphone.request':
|
||||
this._handleRequest();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming location request.
|
||||
*/
|
||||
_handleRequest() {
|
||||
try {
|
||||
// If this is a second request, stop announcing and return
|
||||
if (this._dialog !== null) {
|
||||
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
return;
|
||||
}
|
||||
|
||||
this._dialog = new Dialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
|
||||
this._dialog.connect('response', () => {
|
||||
this._dialog = null;
|
||||
});
|
||||
} catch (e) {
|
||||
this._cancelRequest();
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing ringing and destroy the dialog.
|
||||
*/
|
||||
_cancelRequest() {
|
||||
if (this._dialog !== null)
|
||||
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request that the remote device announce it's location
|
||||
*/
|
||||
ring() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.findmyphone.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._cancelRequest();
|
||||
|
||||
if (this._mixer !== undefined)
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
|
||||
if (this._player !== undefined)
|
||||
this._player = Components.release('sound');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
* Used to ensure 'audible-bell' is enabled for fallback
|
||||
*/
|
||||
const _WM_SETTINGS = new Gio.Settings({
|
||||
schema_id: 'org.gnome.desktop.wm.preferences',
|
||||
path: '/org/gnome/desktop/wm/preferences/',
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A custom GtkMessageDialog for alerting of incoming requests
|
||||
*/
|
||||
const Dialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectFindMyPhoneDialog',
|
||||
Properties: {
|
||||
'device': GObject.ParamSpec.object(
|
||||
'device',
|
||||
'Device',
|
||||
'The device associated with this window',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
'plugin': GObject.ParamSpec.object(
|
||||
'plugin',
|
||||
'Plugin',
|
||||
'The plugin providing messages',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
GObject.Object
|
||||
),
|
||||
},
|
||||
}, class Dialog extends Gtk.MessageDialog {
|
||||
_init(params) {
|
||||
super._init({
|
||||
buttons: Gtk.ButtonsType.CLOSE,
|
||||
device: params.device,
|
||||
image: new Gtk.Image({
|
||||
icon_name: 'phonelink-ring-symbolic',
|
||||
pixel_size: 512,
|
||||
halign: Gtk.Align.CENTER,
|
||||
hexpand: true,
|
||||
valign: Gtk.Align.CENTER,
|
||||
vexpand: true,
|
||||
visible: true,
|
||||
}),
|
||||
plugin: params.plugin,
|
||||
urgency_hint: true,
|
||||
});
|
||||
|
||||
this.set_keep_above(true);
|
||||
this.maximize();
|
||||
this.message_area.destroy();
|
||||
|
||||
// If an output stream is available start fading the volume up
|
||||
if (this.plugin._mixer && this.plugin._mixer.output) {
|
||||
this._stream = this.plugin._mixer.output;
|
||||
|
||||
this._previousMuted = this._stream.muted;
|
||||
this._previousVolume = this._stream.volume;
|
||||
|
||||
this._stream.muted = false;
|
||||
this._stream.fade(0.85, 15);
|
||||
|
||||
// Otherwise ensure audible-bell is enabled
|
||||
} else {
|
||||
this._previousBell = _WM_SETTINGS.get_boolean('audible-bell');
|
||||
_WM_SETTINGS.set_boolean('audible-bell', true);
|
||||
}
|
||||
|
||||
// Start the alarm
|
||||
if (this.plugin._player !== undefined)
|
||||
this.plugin._player.loopSound('phone-incoming-call', this.cancellable);
|
||||
|
||||
// Show the dialog
|
||||
this.show_all();
|
||||
}
|
||||
|
||||
vfunc_key_press_event(event) {
|
||||
this.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
|
||||
return Gdk.EVENT_STOP;
|
||||
}
|
||||
|
||||
vfunc_motion_notify_event(event) {
|
||||
this.response(Gtk.ResponseType.DELETE_EVENT);
|
||||
|
||||
return Gdk.EVENT_STOP;
|
||||
}
|
||||
|
||||
vfunc_response(response_id) {
|
||||
// Stop the alarm
|
||||
this.cancellable.cancel();
|
||||
|
||||
// Restore the mixer level
|
||||
if (this._stream) {
|
||||
this._stream.muted = this._previousMuted;
|
||||
this._stream.fade(this._previousVolume);
|
||||
|
||||
// Restore the audible-bell
|
||||
} else {
|
||||
_WM_SETTINGS.set_boolean('audible-bell', this._previousBell);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
get cancellable() {
|
||||
if (this._cancellable === undefined)
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
|
||||
return this._cancellable;
|
||||
}
|
||||
|
||||
get device() {
|
||||
if (this._device === undefined)
|
||||
this._device = null;
|
||||
|
||||
return this._device;
|
||||
}
|
||||
|
||||
set device(device) {
|
||||
this._device = device;
|
||||
}
|
||||
|
||||
get plugin() {
|
||||
if (this._plugin === undefined)
|
||||
this._plugin = null;
|
||||
|
||||
return this._plugin;
|
||||
}
|
||||
|
||||
set plugin(plugin) {
|
||||
this._plugin = plugin;
|
||||
}
|
||||
});
|
@@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
const Gdk = imports.gi.Gdk;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const {InputDialog} = imports.service.ui.mousepad;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Mousepad'),
|
||||
description: _('Enables the paired device to act as a remote mouse and keyboard'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Mousepad',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.mousepad.echo',
|
||||
'kdeconnect.mousepad.request',
|
||||
'kdeconnect.mousepad.keyboardstate',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.mousepad.echo',
|
||||
'kdeconnect.mousepad.request',
|
||||
'kdeconnect.mousepad.keyboardstate',
|
||||
],
|
||||
actions: {
|
||||
keyboard: {
|
||||
label: _('Keyboard'),
|
||||
icon_name: 'input-keyboard-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: [
|
||||
'kdeconnect.mousepad.echo',
|
||||
'kdeconnect.mousepad.keyboardstate',
|
||||
],
|
||||
outgoing: ['kdeconnect.mousepad.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A map of "KDE Connect" keyvals to Gdk
|
||||
*/
|
||||
const KeyMap = new Map([
|
||||
[1, Gdk.KEY_BackSpace],
|
||||
[2, Gdk.KEY_Tab],
|
||||
[3, Gdk.KEY_Linefeed],
|
||||
[4, Gdk.KEY_Left],
|
||||
[5, Gdk.KEY_Up],
|
||||
[6, Gdk.KEY_Right],
|
||||
[7, Gdk.KEY_Down],
|
||||
[8, Gdk.KEY_Page_Up],
|
||||
[9, Gdk.KEY_Page_Down],
|
||||
[10, Gdk.KEY_Home],
|
||||
[11, Gdk.KEY_End],
|
||||
[12, Gdk.KEY_Return],
|
||||
[13, Gdk.KEY_Delete],
|
||||
[14, Gdk.KEY_Escape],
|
||||
[15, Gdk.KEY_Sys_Req],
|
||||
[16, Gdk.KEY_Scroll_Lock],
|
||||
[17, 0],
|
||||
[18, 0],
|
||||
[19, 0],
|
||||
[20, 0],
|
||||
[21, Gdk.KEY_F1],
|
||||
[22, Gdk.KEY_F2],
|
||||
[23, Gdk.KEY_F3],
|
||||
[24, Gdk.KEY_F4],
|
||||
[25, Gdk.KEY_F5],
|
||||
[26, Gdk.KEY_F6],
|
||||
[27, Gdk.KEY_F7],
|
||||
[28, Gdk.KEY_F8],
|
||||
[29, Gdk.KEY_F9],
|
||||
[30, Gdk.KEY_F10],
|
||||
[31, Gdk.KEY_F11],
|
||||
[32, Gdk.KEY_F12],
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* Mousepad Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mousepad
|
||||
*
|
||||
* TODO: support outgoing mouse events?
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMousepadPlugin',
|
||||
Properties: {
|
||||
'state': GObject.ParamSpec.boolean(
|
||||
'state',
|
||||
'State',
|
||||
'Remote keyboard state',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
},
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'mousepad');
|
||||
|
||||
this._input = Components.acquire('input');
|
||||
|
||||
this._shareControlChangedId = this.settings.connect(
|
||||
'changed::share-control',
|
||||
this._sendState.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get state() {
|
||||
if (this._state === undefined)
|
||||
this._state = false;
|
||||
|
||||
return this._state;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._sendState();
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
super.disconnected();
|
||||
|
||||
this._state = false;
|
||||
this.notify('state');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.mousepad.request':
|
||||
this._handleInput(packet.body);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mousepad.echo':
|
||||
this._handleEcho(packet.body);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mousepad.keyboardstate':
|
||||
this._handleState(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a input event.
|
||||
*
|
||||
* @param {Object} input - The body of a `kdeconnect.mousepad.request`
|
||||
*/
|
||||
_handleInput(input) {
|
||||
if (!this.settings.get_boolean('share-control'))
|
||||
return;
|
||||
|
||||
let keysym;
|
||||
let modifiers = 0;
|
||||
|
||||
// These are ordered, as much as possible, to create the shortest code
|
||||
// path for high-frequency, low-latency events (eg. mouse movement)
|
||||
switch (true) {
|
||||
case input.hasOwnProperty('scroll'):
|
||||
this._input.scrollPointer(input.dx, input.dy);
|
||||
break;
|
||||
|
||||
case (input.hasOwnProperty('dx') && input.hasOwnProperty('dy')):
|
||||
this._input.movePointer(input.dx, input.dy);
|
||||
break;
|
||||
|
||||
case (input.hasOwnProperty('key') || input.hasOwnProperty('specialKey')):
|
||||
// NOTE: \u0000 sometimes sent in advance of a specialKey packet
|
||||
if (input.key && input.key === '\u0000')
|
||||
return;
|
||||
|
||||
// Modifiers
|
||||
if (input.alt)
|
||||
modifiers |= Gdk.ModifierType.MOD1_MASK;
|
||||
|
||||
if (input.ctrl)
|
||||
modifiers |= Gdk.ModifierType.CONTROL_MASK;
|
||||
|
||||
if (input.shift)
|
||||
modifiers |= Gdk.ModifierType.SHIFT_MASK;
|
||||
|
||||
if (input.super)
|
||||
modifiers |= Gdk.ModifierType.SUPER_MASK;
|
||||
|
||||
// Regular key (printable ASCII or Unicode)
|
||||
if (input.key) {
|
||||
this._input.pressKey(input.key, modifiers);
|
||||
this._sendEcho(input);
|
||||
|
||||
// Special key (eg. non-printable ASCII)
|
||||
} else if (input.specialKey && KeyMap.has(input.specialKey)) {
|
||||
keysym = KeyMap.get(input.specialKey);
|
||||
this._input.pressKey(keysym, modifiers);
|
||||
this._sendEcho(input);
|
||||
}
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('singleclick'):
|
||||
this._input.clickPointer(Gdk.BUTTON_PRIMARY);
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('doubleclick'):
|
||||
this._input.doubleclickPointer(Gdk.BUTTON_PRIMARY);
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('middleclick'):
|
||||
this._input.clickPointer(Gdk.BUTTON_MIDDLE);
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('rightclick'):
|
||||
this._input.clickPointer(Gdk.BUTTON_SECONDARY);
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('singlehold'):
|
||||
this._input.pressPointer(Gdk.BUTTON_PRIMARY);
|
||||
break;
|
||||
|
||||
case input.hasOwnProperty('singlerelease'):
|
||||
this._input.releasePointer(Gdk.BUTTON_PRIMARY);
|
||||
break;
|
||||
|
||||
default:
|
||||
logError(new Error('Unknown input'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an echo/ACK of a event we sent, displaying it the dialog entry.
|
||||
*
|
||||
* @param {Object} input - The body of a `kdeconnect.mousepad.echo`
|
||||
*/
|
||||
_handleEcho(input) {
|
||||
if (!this._dialog || !this._dialog.visible)
|
||||
return;
|
||||
|
||||
// Skip modifiers
|
||||
if (input.alt || input.ctrl || input.super)
|
||||
return;
|
||||
|
||||
if (input.key) {
|
||||
this._dialog._isAck = true;
|
||||
this._dialog.text.buffer.text += input.key;
|
||||
this._dialog._isAck = false;
|
||||
} else if (KeyMap.get(input.specialKey) === Gdk.KEY_BackSpace) {
|
||||
this._dialog.text.emit('backspace');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a state change from the remote keyboard. This is an indication
|
||||
* that the remote keyboard is ready to accept input.
|
||||
*
|
||||
* @param {Object} packet - A `kdeconnect.mousepad.keyboardstate` packet
|
||||
*/
|
||||
_handleState(packet) {
|
||||
this._state = !!packet.body.state;
|
||||
this.notify('state');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an echo/ACK of @input, if requested
|
||||
*
|
||||
* @param {Object} input - The body of a 'kdeconnect.mousepad.request'
|
||||
*/
|
||||
_sendEcho(input) {
|
||||
if (!input.sendAck)
|
||||
return;
|
||||
|
||||
delete input.sendAck;
|
||||
input.isAck = true;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mousepad.echo',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the local keyboard state
|
||||
*
|
||||
* @param {boolean} state - Whether we're ready to accept input
|
||||
*/
|
||||
_sendState() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mousepad.keyboardstate',
|
||||
body: {
|
||||
state: this.settings.get_boolean('share-control'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Keyboard Input dialog
|
||||
*/
|
||||
keyboard() {
|
||||
if (this._dialog === undefined) {
|
||||
this._dialog = new InputDialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
}
|
||||
|
||||
this._dialog.present();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._input !== undefined)
|
||||
this._input = Components.release('input');
|
||||
|
||||
if (this._dialog !== undefined)
|
||||
this._dialog.destroy();
|
||||
|
||||
this.settings.disconnect(this._shareControlChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,902 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const DBus = imports.service.utils.dbus;
|
||||
const MPRIS = imports.service.components.mpris;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('MPRIS'),
|
||||
description: _('Bidirectional remote media playback control'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS',
|
||||
incomingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
|
||||
outgoingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* MPRIS Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mpriscontrol
|
||||
*
|
||||
* See also:
|
||||
* https://specifications.freedesktop.org/mpris-spec/latest/
|
||||
* https://github.com/GNOME/gnome-shell/blob/master/js/ui/mpris.js
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMPRISPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'mpris');
|
||||
|
||||
this._players = new Map();
|
||||
this._transferring = new WeakSet();
|
||||
this._updating = new WeakSet();
|
||||
|
||||
this._mpris = Components.acquire('mpris');
|
||||
|
||||
this._playerAddedId = this._mpris.connect(
|
||||
'player-added',
|
||||
this._sendPlayerList.bind(this)
|
||||
);
|
||||
|
||||
this._playerRemovedId = this._mpris.connect(
|
||||
'player-removed',
|
||||
this._sendPlayerList.bind(this)
|
||||
);
|
||||
|
||||
this._playerChangedId = this._mpris.connect(
|
||||
'player-changed',
|
||||
this._onPlayerChanged.bind(this)
|
||||
);
|
||||
|
||||
this._playerSeekedId = this._mpris.connect(
|
||||
'player-seeked',
|
||||
this._onPlayerSeeked.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestPlayerList();
|
||||
this._sendPlayerList();
|
||||
}
|
||||
|
||||
disconnected() {
|
||||
super.disconnected();
|
||||
|
||||
for (const [identity, player] of this._players) {
|
||||
this._players.delete(identity);
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.mpris':
|
||||
this._handleUpdate(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.mpris.request':
|
||||
this._handleRequest(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote player update.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris`
|
||||
*/
|
||||
_handleUpdate(packet) {
|
||||
try {
|
||||
if (packet.body.hasOwnProperty('playerList'))
|
||||
this._handlePlayerList(packet.body.playerList);
|
||||
else if (packet.body.hasOwnProperty('player'))
|
||||
this._handlePlayerUpdate(packet);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an updated list of remote players.
|
||||
*
|
||||
* @param {string[]} playerList - A list of remote player names
|
||||
*/
|
||||
_handlePlayerList(playerList) {
|
||||
// Destroy removed players before adding new ones
|
||||
for (const player of this._players.values()) {
|
||||
if (!playerList.includes(player.Identity)) {
|
||||
this._players.delete(player.Identity);
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
for (const identity of playerList) {
|
||||
if (!this._players.has(identity)) {
|
||||
const player = new PlayerRemote(this.device, identity);
|
||||
this._players.set(identity, player);
|
||||
}
|
||||
|
||||
// Always request player updates; packets are cheap
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: identity,
|
||||
requestNowPlaying: true,
|
||||
requestVolume: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an update for a remote player.
|
||||
*
|
||||
* @param {Object} packet - A `kdeconnect.mpris` packet
|
||||
*/
|
||||
_handlePlayerUpdate(packet) {
|
||||
const player = this._players.get(packet.body.player);
|
||||
|
||||
if (player === undefined)
|
||||
return;
|
||||
|
||||
if (packet.body.hasOwnProperty('transferringAlbumArt'))
|
||||
player.handleAlbumArt(packet);
|
||||
else
|
||||
player.update(packet.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of remote players.
|
||||
*/
|
||||
_requestPlayerList() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
requestPlayerList: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request for player information or action.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.mpris.request`
|
||||
* @return {undefined} no return value
|
||||
*/
|
||||
_handleRequest(packet) {
|
||||
// A request for the list of players
|
||||
if (packet.body.hasOwnProperty('requestPlayerList'))
|
||||
return this._sendPlayerList();
|
||||
|
||||
// A request for an unknown player; send the list of players
|
||||
if (!this._mpris.hasPlayer(packet.body.player))
|
||||
return this._sendPlayerList();
|
||||
|
||||
// An album art request
|
||||
if (packet.body.hasOwnProperty('albumArtUrl'))
|
||||
return this._sendAlbumArt(packet);
|
||||
|
||||
// A player command
|
||||
this._handleCommand(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming player command or information request
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris.request`
|
||||
*/
|
||||
async _handleCommand(packet) {
|
||||
if (!this.settings.get_boolean('share-players'))
|
||||
return;
|
||||
|
||||
let player;
|
||||
|
||||
try {
|
||||
player = this._mpris.getPlayer(packet.body.player);
|
||||
|
||||
if (player === undefined || this._updating.has(player))
|
||||
return;
|
||||
|
||||
this._updating.add(player);
|
||||
|
||||
// Player Actions
|
||||
if (packet.body.hasOwnProperty('action')) {
|
||||
switch (packet.body.action) {
|
||||
case 'PlayPause':
|
||||
case 'Play':
|
||||
case 'Pause':
|
||||
case 'Next':
|
||||
case 'Previous':
|
||||
case 'Stop':
|
||||
player[packet.body.action]();
|
||||
break;
|
||||
|
||||
default:
|
||||
debug(`unknown action: ${packet.body.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Player Properties
|
||||
if (packet.body.hasOwnProperty('setLoopStatus'))
|
||||
player.LoopStatus = packet.body.setLoopStatus;
|
||||
|
||||
if (packet.body.hasOwnProperty('setShuffle'))
|
||||
player.Shuffle = packet.body.setShuffle;
|
||||
|
||||
if (packet.body.hasOwnProperty('setVolume'))
|
||||
player.Volume = packet.body.setVolume / 100;
|
||||
|
||||
if (packet.body.hasOwnProperty('Seek'))
|
||||
await player.Seek(packet.body.Seek * 1000);
|
||||
|
||||
if (packet.body.hasOwnProperty('SetPosition')) {
|
||||
const offset = (packet.body.SetPosition * 1000) - player.Position;
|
||||
await player.Seek(offset);
|
||||
}
|
||||
|
||||
// Information Request
|
||||
let hasResponse = false;
|
||||
|
||||
const response = {
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
player: packet.body.player,
|
||||
},
|
||||
};
|
||||
|
||||
if (packet.body.hasOwnProperty('requestNowPlaying')) {
|
||||
hasResponse = true;
|
||||
|
||||
Object.assign(response.body, {
|
||||
pos: Math.floor(player.Position / 1000),
|
||||
isPlaying: (player.PlaybackStatus === 'Playing'),
|
||||
canPause: player.CanPause,
|
||||
canPlay: player.CanPlay,
|
||||
canGoNext: player.CanGoNext,
|
||||
canGoPrevious: player.CanGoPrevious,
|
||||
canSeek: player.CanSeek,
|
||||
loopStatus: player.LoopStatus,
|
||||
shuffle: player.Shuffle,
|
||||
|
||||
// default values for members that will be filled conditionally
|
||||
albumArtUrl: '',
|
||||
length: 0,
|
||||
artist: '',
|
||||
title: '',
|
||||
album: '',
|
||||
nowPlaying: '',
|
||||
volume: 0,
|
||||
});
|
||||
|
||||
const metadata = player.Metadata;
|
||||
|
||||
if (metadata.hasOwnProperty('mpris:artUrl')) {
|
||||
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
|
||||
response.body.albumArtUrl = file.get_uri();
|
||||
}
|
||||
|
||||
if (metadata.hasOwnProperty('mpris:length')) {
|
||||
const trackLen = Math.floor(metadata['mpris:length'] / 1000);
|
||||
response.body.length = trackLen;
|
||||
}
|
||||
|
||||
if (metadata.hasOwnProperty('xesam:artist')) {
|
||||
const artists = metadata['xesam:artist'];
|
||||
response.body.artist = artists.join(', ');
|
||||
}
|
||||
|
||||
if (metadata.hasOwnProperty('xesam:title'))
|
||||
response.body.title = metadata['xesam:title'];
|
||||
|
||||
if (metadata.hasOwnProperty('xesam:album'))
|
||||
response.body.album = metadata['xesam:album'];
|
||||
|
||||
// Now Playing
|
||||
if (response.body.artist && response.body.title) {
|
||||
response.body.nowPlaying = [
|
||||
response.body.artist,
|
||||
response.body.title,
|
||||
].join(' - ');
|
||||
} else if (response.body.artist) {
|
||||
response.body.nowPlaying = response.body.artist;
|
||||
} else if (response.body.title) {
|
||||
response.body.nowPlaying = response.body.title;
|
||||
} else {
|
||||
response.body.nowPlaying = _('Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.body.hasOwnProperty('requestVolume')) {
|
||||
hasResponse = true;
|
||||
response.body.volume = Math.floor(player.Volume * 100);
|
||||
}
|
||||
|
||||
if (hasResponse)
|
||||
this.device.sendPacket(response);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
} finally {
|
||||
this._updating.delete(player);
|
||||
}
|
||||
}
|
||||
|
||||
_onPlayerChanged(mpris, player) {
|
||||
if (!this.settings.get_boolean('share-players'))
|
||||
return;
|
||||
|
||||
this._handleCommand({
|
||||
body: {
|
||||
player: player.Identity,
|
||||
requestNowPlaying: true,
|
||||
requestVolume: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_onPlayerSeeked(mpris, player, offset) {
|
||||
// TODO: although we can handle full seeked signals, kdeconnect-android
|
||||
// does not, and expects a position update instead
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
player: player.Identity,
|
||||
pos: Math.floor(player.Position / 1000),
|
||||
// Seek: Math.floor(offset / 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _sendAlbumArt(packet) {
|
||||
let player;
|
||||
|
||||
try {
|
||||
// Reject concurrent requests for album art
|
||||
player = this._mpris.getPlayer(packet.body.player);
|
||||
|
||||
if (player === undefined || this._transferring.has(player))
|
||||
return;
|
||||
|
||||
// Ensure the requested albumArtUrl matches the current mpris:artUrl
|
||||
const metadata = player.Metadata;
|
||||
|
||||
if (!metadata.hasOwnProperty('mpris:artUrl'))
|
||||
return;
|
||||
|
||||
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
|
||||
const request = Gio.File.new_for_uri(packet.body.albumArtUrl);
|
||||
|
||||
if (file.get_uri() !== request.get_uri())
|
||||
throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);
|
||||
|
||||
// Transfer the album art
|
||||
this._transferring.add(player);
|
||||
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
transferringAlbumArt: true,
|
||||
player: packet.body.player,
|
||||
albumArtUrl: packet.body.albumArtUrl,
|
||||
},
|
||||
}, file);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
} finally {
|
||||
this._transferring.delete(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the list of player identities and indicate whether we support
|
||||
* transferring album art
|
||||
*/
|
||||
_sendPlayerList() {
|
||||
let playerList = [];
|
||||
|
||||
if (this.settings.get_boolean('share-players'))
|
||||
playerList = this._mpris.getIdentities();
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris',
|
||||
body: {
|
||||
playerList: playerList,
|
||||
supportAlbumArtPayload: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mpris !== undefined) {
|
||||
this._mpris.disconnect(this._playerAddedId);
|
||||
this._mpris.disconnect(this._playerRemovedId);
|
||||
this._mpris.disconnect(this._playerChangedId);
|
||||
this._mpris.disconnect(this._playerSeekedId);
|
||||
this._mpris = Components.release('mpris');
|
||||
}
|
||||
|
||||
for (const [identity, player] of this._players) {
|
||||
this._players.delete(identity);
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
* A class for mirroring a remote Media Player on DBus
|
||||
*/
|
||||
const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2');
|
||||
const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player');
|
||||
|
||||
|
||||
const PlayerRemote = GObject.registerClass({
|
||||
GTypeName: 'GSConnectMPRISPlayerRemote',
|
||||
}, class PlayerRemote extends MPRIS.Player {
|
||||
|
||||
_init(device, identity) {
|
||||
super._init();
|
||||
|
||||
this._device = device;
|
||||
this._Identity = identity;
|
||||
this._isPlaying = false;
|
||||
|
||||
this._artist = null;
|
||||
this._title = null;
|
||||
this._album = null;
|
||||
this._length = 0;
|
||||
this._artUrl = null;
|
||||
|
||||
this._ownerId = 0;
|
||||
this._connection = null;
|
||||
this._applicationIface = null;
|
||||
this._playerIface = null;
|
||||
}
|
||||
|
||||
_getFile(albumArtUrl) {
|
||||
const hash = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5,
|
||||
albumArtUrl, -1);
|
||||
const path = GLib.build_filenamev([Config.CACHEDIR, hash]);
|
||||
|
||||
return Gio.File.new_for_uri(`file://${path}`);
|
||||
}
|
||||
|
||||
_requestAlbumArt(state) {
|
||||
if (this._artUrl === state.albumArtUrl)
|
||||
return;
|
||||
|
||||
const file = this._getFile(state.albumArtUrl);
|
||||
|
||||
if (file.query_exists(null)) {
|
||||
this._artUrl = file.get_uri();
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
} else {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
albumArtUrl: state.albumArtUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateMetadata(state) {
|
||||
let metadataChanged = false;
|
||||
|
||||
if (state.hasOwnProperty('artist')) {
|
||||
if (this._artist !== state.artist) {
|
||||
this._artist = state.artist;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._artist) {
|
||||
this._artist = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('title')) {
|
||||
if (this._title !== state.title) {
|
||||
this._title = state.title;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._title) {
|
||||
this._title = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('album')) {
|
||||
if (this._album !== state.album) {
|
||||
this._album = state.album;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._album) {
|
||||
this._album = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('length')) {
|
||||
if (this._length !== state.length * 1000) {
|
||||
this._length = state.length * 1000;
|
||||
metadataChanged = true;
|
||||
}
|
||||
} else if (this._length) {
|
||||
this._length = 0;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('albumArtUrl')) {
|
||||
this._requestAlbumArt(state);
|
||||
} else if (this._artUrl) {
|
||||
this._artUrl = null;
|
||||
metadataChanged = true;
|
||||
}
|
||||
|
||||
if (metadataChanged) {
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async export() {
|
||||
try {
|
||||
if (this._connection === null) {
|
||||
this._connection = await DBus.newConnection();
|
||||
|
||||
if (this._applicationIface === null) {
|
||||
this._applicationIface = new DBus.Interface({
|
||||
g_instance: this,
|
||||
g_connection: this._connection,
|
||||
g_object_path: '/org/mpris/MediaPlayer2',
|
||||
g_interface_info: MPRISIface,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._playerIface === null) {
|
||||
this._playerIface = new DBus.Interface({
|
||||
g_instance: this,
|
||||
g_connection: this._connection,
|
||||
g_object_path: '/org/mpris/MediaPlayer2',
|
||||
g_interface_info: MPRISPlayerIface,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this._ownerId !== 0)
|
||||
return;
|
||||
|
||||
const name = [
|
||||
this.device.name,
|
||||
this.Identity,
|
||||
].join('').replace(/[\W]*/g, '');
|
||||
|
||||
this._ownerId = Gio.bus_own_name_on_connection(
|
||||
this._connection,
|
||||
`org.mpris.MediaPlayer2.GSConnect.${name}`,
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
null,
|
||||
null
|
||||
);
|
||||
} catch (e) {
|
||||
debug(e, this.Identity);
|
||||
}
|
||||
}
|
||||
|
||||
unexport() {
|
||||
if (this._ownerId === 0)
|
||||
return;
|
||||
|
||||
Gio.bus_unown_name(this._ownerId);
|
||||
this._ownerId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download album art for the current track of the remote player.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.mpris` packet
|
||||
*/
|
||||
async handleAlbumArt(packet) {
|
||||
let file;
|
||||
|
||||
try {
|
||||
file = this._getFile(packet.body.albumArtUrl);
|
||||
|
||||
// Transfer the album art
|
||||
const transfer = this.device.createTransfer();
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
await transfer.start();
|
||||
|
||||
this._artUrl = file.get_uri();
|
||||
this._Metadata = undefined;
|
||||
this.notify('Metadata');
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (file)
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the internal state of the media player.
|
||||
*
|
||||
* @param {Core.Packet} state - The body of a `kdeconnect.mpris` packet
|
||||
*/
|
||||
update(state) {
|
||||
this.freeze_notify();
|
||||
|
||||
// Metadata
|
||||
if (state.hasOwnProperty('nowPlaying'))
|
||||
this._updateMetadata(state);
|
||||
|
||||
// Playback Status
|
||||
if (state.hasOwnProperty('isPlaying')) {
|
||||
if (this._isPlaying !== state.isPlaying) {
|
||||
this._isPlaying = state.isPlaying;
|
||||
this.notify('PlaybackStatus');
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('canPlay')) {
|
||||
if (this.CanPlay !== state.canPlay) {
|
||||
this._CanPlay = state.canPlay;
|
||||
this.notify('CanPlay');
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('canPause')) {
|
||||
if (this.CanPause !== state.canPause) {
|
||||
this._CanPause = state.canPause;
|
||||
this.notify('CanPause');
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('canGoNext')) {
|
||||
if (this.CanGoNext !== state.canGoNext) {
|
||||
this._CanGoNext = state.canGoNext;
|
||||
this.notify('CanGoNext');
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('canGoPrevious')) {
|
||||
if (this.CanGoPrevious !== state.canGoPrevious) {
|
||||
this._CanGoPrevious = state.canGoPrevious;
|
||||
this.notify('CanGoPrevious');
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasOwnProperty('pos'))
|
||||
this._Position = state.pos * 1000;
|
||||
|
||||
if (state.hasOwnProperty('volume')) {
|
||||
if (this.Volume !== state.volume / 100) {
|
||||
this._Volume = state.volume / 100;
|
||||
this.notify('Volume');
|
||||
}
|
||||
}
|
||||
|
||||
this.thaw_notify();
|
||||
|
||||
if (!this._isPlaying && !this.CanControl)
|
||||
this.unexport();
|
||||
else
|
||||
this.export();
|
||||
}
|
||||
|
||||
/*
|
||||
* Native properties
|
||||
*/
|
||||
get device() {
|
||||
return this._device;
|
||||
}
|
||||
|
||||
/*
|
||||
* The org.mpris.MediaPlayer2.Player Interface
|
||||
*/
|
||||
get CanControl() {
|
||||
return (this.CanPlay || this.CanPause);
|
||||
}
|
||||
|
||||
get Metadata() {
|
||||
if (this._Metadata === undefined) {
|
||||
this._Metadata = {};
|
||||
|
||||
if (this._artist) {
|
||||
this._Metadata['xesam:artist'] = new GLib.Variant('as',
|
||||
[this._artist]);
|
||||
}
|
||||
|
||||
if (this._title) {
|
||||
this._Metadata['xesam:title'] = new GLib.Variant('s',
|
||||
this._title);
|
||||
}
|
||||
|
||||
if (this._album) {
|
||||
this._Metadata['xesam:album'] = new GLib.Variant('s',
|
||||
this._album);
|
||||
}
|
||||
|
||||
if (this._artUrl) {
|
||||
this._Metadata['mpris:artUrl'] = new GLib.Variant('s',
|
||||
this._artUrl);
|
||||
}
|
||||
|
||||
this._Metadata['mpris:length'] = new GLib.Variant('x',
|
||||
this._length);
|
||||
}
|
||||
|
||||
return this._Metadata;
|
||||
}
|
||||
|
||||
get PlaybackStatus() {
|
||||
if (this._isPlaying)
|
||||
return 'Playing';
|
||||
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
get Volume() {
|
||||
if (this._Volume === undefined)
|
||||
this._Volume = 0.3;
|
||||
|
||||
return this._Volume;
|
||||
}
|
||||
|
||||
set Volume(level) {
|
||||
if (this._Volume === level)
|
||||
return;
|
||||
|
||||
this._Volume = level;
|
||||
this.notify('Volume');
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
setVolume: Math.floor(this._Volume * 100),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Next() {
|
||||
if (!this.CanGoNext)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Next',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Pause() {
|
||||
if (!this.CanPause)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Pause',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Play() {
|
||||
if (!this.CanPlay)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Play',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
PlayPause() {
|
||||
if (!this.CanPlay && !this.CanPause)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'PlayPause',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Previous() {
|
||||
if (!this.CanGoPrevious)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Previous',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Seek(offset) {
|
||||
if (!this.CanSeek)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
Seek: offset,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
SetPosition(trackId, position) {
|
||||
debug(`${this._Identity}: SetPosition(${trackId}, ${position})`);
|
||||
|
||||
if (!this.CanControl || !this.CanSeek)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
SetPosition: position / 1000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Stop() {
|
||||
if (!this.CanControl)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.mpris.request',
|
||||
body: {
|
||||
player: this.Identity,
|
||||
action: 'Stop',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unexport();
|
||||
|
||||
if (this._connection) {
|
||||
this._connection.close(null, null);
|
||||
this._connection = null;
|
||||
|
||||
if (this._applicationIface) {
|
||||
this._applicationIface.destroy();
|
||||
this._applicationIface = null;
|
||||
}
|
||||
|
||||
if (this._playerIface) {
|
||||
this._playerIface.destroy();
|
||||
this._playerIface = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
@@ -0,0 +1,713 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
const NotificationUI = imports.service.ui.notification;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Notifications'),
|
||||
description: _('Share notifications with the paired device'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.notification',
|
||||
'kdeconnect.notification.request',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.notification',
|
||||
'kdeconnect.notification.action',
|
||||
'kdeconnect.notification.reply',
|
||||
'kdeconnect.notification.request',
|
||||
],
|
||||
actions: {
|
||||
withdrawNotification: {
|
||||
label: _('Cancel Notification'),
|
||||
icon_name: 'preferences-system-notifications-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.notification'],
|
||||
},
|
||||
closeNotification: {
|
||||
label: _('Close Notification'),
|
||||
icon_name: 'preferences-system-notifications-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.notification.request'],
|
||||
},
|
||||
replyNotification: {
|
||||
label: _('Reply Notification'),
|
||||
icon_name: 'preferences-system-notifications-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('(ssa{ss})'),
|
||||
incoming: ['kdeconnect.notification'],
|
||||
outgoing: ['kdeconnect.notification.reply'],
|
||||
},
|
||||
sendNotification: {
|
||||
label: _('Send Notification'),
|
||||
icon_name: 'preferences-system-notifications-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('a{sv}'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.notification'],
|
||||
},
|
||||
activateNotification: {
|
||||
label: _('Activate Notification'),
|
||||
icon_name: 'preferences-system-notifications-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('(ss)'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.notification.action'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// A regex for our custom notificaiton ids
|
||||
const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/;
|
||||
|
||||
// A list of known SMS apps
|
||||
const SMS_APPS = [
|
||||
// Popular apps that don't contain the string 'sms'
|
||||
'com.android.messaging', // AOSP
|
||||
'com.google.android.apps.messaging', // Google Messages
|
||||
'com.textra', // Textra
|
||||
'xyz.klinker.messenger', // Pulse
|
||||
'com.calea.echo', // Mood Messenger
|
||||
'com.moez.QKSMS', // QKSMS
|
||||
'rpkandrodev.yaata', // YAATA
|
||||
'com.tencent.mm', // WeChat
|
||||
'com.viber.voip', // Viber
|
||||
'com.kakao.talk', // KakaoTalk
|
||||
'com.concentriclivers.mms.com.android.mms', // AOSP Clone
|
||||
'fr.slvn.mms', // AOSP Clone
|
||||
'com.promessage.message', //
|
||||
'com.htc.sense.mms', // HTC Messages
|
||||
|
||||
// Known not to work with sms plugin
|
||||
'org.thoughtcrime.securesms', // Signal Private Messenger
|
||||
'com.samsung.android.messaging', // Samsung Messages
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Try to determine if an notification is from an SMS app
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @return {boolean} Whether the notification is from an SMS app
|
||||
*/
|
||||
function _isSmsNotification(packet) {
|
||||
const id = packet.body.id;
|
||||
|
||||
if (id.includes('sms'))
|
||||
return true;
|
||||
|
||||
for (let i = 0, len = SMS_APPS.length; i < len; i++) {
|
||||
if (id.includes(SMS_APPS[i]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove a local libnotify or Gtk notification.
|
||||
*
|
||||
* @param {String|Number} id - Gtk (string) or libnotify id (uint32)
|
||||
* @param {String|null} application - Application Id if Gtk or null
|
||||
*/
|
||||
function _removeNotification(id, application = null) {
|
||||
let name, path, method, variant;
|
||||
|
||||
if (application !== null) {
|
||||
name = 'org.gtk.Notifications';
|
||||
method = 'RemoveNotification';
|
||||
path = '/org/gtk/Notifications';
|
||||
variant = new GLib.Variant('(ss)', [application, id]);
|
||||
} else {
|
||||
name = 'org.freedesktop.Notifications';
|
||||
path = '/org/freedesktop/Notifications';
|
||||
method = 'CloseNotification';
|
||||
variant = new GLib.Variant('(u)', [id]);
|
||||
}
|
||||
|
||||
Gio.DBus.session.call(
|
||||
name, path, name, method, variant, null,
|
||||
Gio.DBusCallFlags.NONE, -1, null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
connection.call_finish(res);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notification Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNotificationPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'notification');
|
||||
|
||||
this._listener = Components.acquire('notification');
|
||||
this._session = Components.acquire('session');
|
||||
|
||||
this._notificationAddedId = this._listener.connect(
|
||||
'notification-added',
|
||||
this._onNotificationAdded.bind(this)
|
||||
);
|
||||
|
||||
// Load application notification settings
|
||||
this._applicationsChangedId = this.settings.connect(
|
||||
'changed::applications',
|
||||
this._onApplicationsChanged.bind(this)
|
||||
);
|
||||
this._onApplicationsChanged(this.settings, 'applications');
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._requestNotifications();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.notification':
|
||||
this._handleNotification(packet);
|
||||
break;
|
||||
|
||||
// TODO
|
||||
case 'kdeconnect.notification.action':
|
||||
this._handleNotificationAction(packet);
|
||||
break;
|
||||
|
||||
// No Linux/BSD desktop notifications are repliable as yet
|
||||
case 'kdeconnect.notification.reply':
|
||||
debug(`Not implemented: ${packet.type}`);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.notification.request':
|
||||
this._handleNotificationRequest(packet);
|
||||
break;
|
||||
|
||||
default:
|
||||
debug(`Unknown notification packet: ${packet.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
_onApplicationsChanged(settings, key) {
|
||||
if (this._applicationsChangedSkip)
|
||||
return;
|
||||
|
||||
try {
|
||||
const json = settings.get_string(key);
|
||||
this._applications = JSON.parse(json);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
this._applicationsChangedSkip = true;
|
||||
settings.set_string(key, '{}');
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
}
|
||||
|
||||
_onNotificationAdded(listener, notification) {
|
||||
try {
|
||||
const notif = notification.full_unpack();
|
||||
|
||||
// An unconfigured application
|
||||
if (notif.appName && !this._applications[notif.appName]) {
|
||||
this._applications[notif.appName] = {
|
||||
iconName: 'system-run-symbolic',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Store the themed icons for the device preferences window
|
||||
if (notif.icon === undefined) {
|
||||
// Keep default
|
||||
|
||||
} else if (typeof notif.icon === 'string') {
|
||||
this._applications[notif.appName].iconName = notif.icon;
|
||||
|
||||
} else if (notif.icon instanceof Gio.ThemedIcon) {
|
||||
const iconName = notif.icon.get_names()[0];
|
||||
this._applications[notif.appName].iconName = iconName;
|
||||
}
|
||||
|
||||
this._applicationsChangedSkip = true;
|
||||
this.settings.set_string(
|
||||
'applications',
|
||||
JSON.stringify(this._applications)
|
||||
);
|
||||
this._applicationsChangedSkip = false;
|
||||
}
|
||||
|
||||
// Sending notifications forbidden
|
||||
if (!this.settings.get_boolean('send-notifications'))
|
||||
return;
|
||||
|
||||
// Sending when the session is active is forbidden
|
||||
if (!this.settings.get_boolean('send-active') && this._session.active)
|
||||
return;
|
||||
|
||||
// Notifications disabled for this application
|
||||
if (notif.appName && !this._applications[notif.appName].enabled)
|
||||
return;
|
||||
|
||||
this.sendNotification(notif);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming notification or closed report.
|
||||
*
|
||||
* FIXME: upstream kdeconnect-android is tagging many notifications as
|
||||
* `silent`, causing them to never be shown. Since we already handle
|
||||
* duplicates in the Shell, we ignore that flag for now.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
*/
|
||||
_handleNotification(packet) {
|
||||
// A report that a remote notification has been dismissed
|
||||
if (packet.body.hasOwnProperty('isCancel'))
|
||||
this.device.hideNotification(packet.body.id);
|
||||
|
||||
// A normal, remote notification
|
||||
else
|
||||
this._receiveNotification(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request to activate a notification action.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification.action`
|
||||
*/
|
||||
_handleNotificationAction(packet) {
|
||||
throw new GObject.NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request to close or list notifications.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification.request`
|
||||
*/
|
||||
_handleNotificationRequest(packet) {
|
||||
// A request for our notifications. This isn't implemented and would be
|
||||
// pretty hard to without communicating with GNOME Shell.
|
||||
if (packet.body.hasOwnProperty('request'))
|
||||
return;
|
||||
|
||||
// A request to close a local notification
|
||||
//
|
||||
// TODO: kdeconnect-android doesn't send these, and will instead send a
|
||||
// kdeconnect.notification packet with isCancel and an id of "0".
|
||||
//
|
||||
// For clients that do support it, we report notification ids in the
|
||||
// form "type|application-id|notification-id" so we can close it with
|
||||
// the appropriate service.
|
||||
if (packet.body.hasOwnProperty('cancel')) {
|
||||
const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
|
||||
|
||||
if (type === 'fdo')
|
||||
_removeNotification(parseInt(id));
|
||||
else if (type === 'gtk')
|
||||
_removeNotification(id, application);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a GLib.Bytes object.
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet for the notification
|
||||
* @param {GLib.Bytes} bytes - The icon bytes
|
||||
*/
|
||||
_uploadBytesIcon(packet, bytes) {
|
||||
const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
|
||||
this._uploadIconStream(packet, stream, bytes.get_size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a Gio.File object.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @param {Gio.File} file - A file object for the icon
|
||||
*/
|
||||
async _uploadFileIcon(packet, file) {
|
||||
const read = new Promise((resolve, reject) => {
|
||||
file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
|
||||
try {
|
||||
resolve(file.read_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const query = new Promise((resolve, reject) => {
|
||||
file.query_info_async(
|
||||
'standard::size',
|
||||
Gio.FileQueryInfoFlags.NONE,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.query_info_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const [stream, info] = await Promise.all([read, query]);
|
||||
|
||||
this._uploadIconStream(packet, stream, info.get_size());
|
||||
}
|
||||
|
||||
/**
|
||||
* A function for uploading GThemedIcons
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet for the notification
|
||||
* @param {Gio.ThemedIcon} icon - The GIcon to upload
|
||||
*/
|
||||
_uploadThemedIcon(packet, icon) {
|
||||
const theme = Gtk.IconTheme.get_default();
|
||||
let file = null;
|
||||
|
||||
for (const name of icon.names) {
|
||||
// NOTE: kdeconnect-android doesn't support SVGs
|
||||
const size = Math.max.apply(null, theme.get_icon_sizes(name));
|
||||
const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);
|
||||
|
||||
// Send the first icon we find from the options
|
||||
if (info) {
|
||||
file = Gio.File.new_for_path(info.get_filename());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (file)
|
||||
this._uploadFileIcon(packet, file);
|
||||
else
|
||||
this.device.sendPacket(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* All icon types end up being uploaded in this function.
|
||||
*
|
||||
* @param {Core.Packet} packet - The packet for the notification
|
||||
* @param {Gio.InputStream} stream - A stream to read the icon bytes from
|
||||
* @param {number} size - Size of the icon in bytes
|
||||
*/
|
||||
async _uploadIconStream(packet, stream, size) {
|
||||
try {
|
||||
const transfer = this.device.createTransfer();
|
||||
transfer.addStream(packet, stream, size);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e);
|
||||
|
||||
this.device.sendPacket(packet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an icon from a GIcon or themed icon name.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
* @param {Gio.Icon|string|null} icon - An icon or %null
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_uploadIcon(packet, icon = null) {
|
||||
// Normalize strings into GIcons
|
||||
if (typeof icon === 'string')
|
||||
icon = Gio.Icon.new_for_string(icon);
|
||||
|
||||
if (icon instanceof Gio.ThemedIcon)
|
||||
return this._uploadThemedIcon(packet, icon);
|
||||
|
||||
if (icon instanceof Gio.FileIcon)
|
||||
return this._uploadFileIcon(packet, icon.get_file());
|
||||
|
||||
if (icon instanceof Gio.BytesIcon)
|
||||
return this._uploadBytesIcon(packet, icon.get_bytes());
|
||||
|
||||
return this.device.sendPacket(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a local notification to the remote device.
|
||||
*
|
||||
* @param {Object} notif - A dictionary of notification parameters
|
||||
* @param {string} notif.appName - The notifying application
|
||||
* @param {string} notif.id - The notification ID
|
||||
* @param {string} notif.title - The notification title
|
||||
* @param {string} notif.body - The notification body
|
||||
* @param {string} notif.ticker - The notification title & body
|
||||
* @param {boolean} notif.isClearable - If the notification can be closed
|
||||
* @param {string|Gio.Icon} notif.icon - An icon name or GIcon
|
||||
*/
|
||||
async sendNotification(notif) {
|
||||
try {
|
||||
const icon = notif.icon || null;
|
||||
delete notif.icon;
|
||||
|
||||
await this._uploadIcon({
|
||||
type: 'kdeconnect.notification',
|
||||
body: notif,
|
||||
}, icon);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async _downloadIcon(packet) {
|
||||
try {
|
||||
if (!packet.hasPayload())
|
||||
return null;
|
||||
|
||||
// Save the file in the global cache
|
||||
const path = GLib.build_filenamev([
|
||||
Config.CACHEDIR,
|
||||
packet.body.payloadHash || `${Date.now()}`,
|
||||
]);
|
||||
|
||||
// Check if we've already downloaded this icon
|
||||
// NOTE: if we reject the transfer kdeconnect-android will resend
|
||||
// the notification packet, which may cause problems wrt #789
|
||||
const file = Gio.File.new_for_path(path);
|
||||
|
||||
if (file.query_exists(null))
|
||||
return new Gio.FileIcon({file: file});
|
||||
|
||||
// Open the target path and create a transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
return new Gio.FileIcon({file: file});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive an incoming notification.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
||||
*/
|
||||
async _receiveNotification(packet) {
|
||||
try {
|
||||
// Set defaults
|
||||
let action = null;
|
||||
let buttons = [];
|
||||
let id = packet.body.id;
|
||||
let title = packet.body.appName;
|
||||
let body = `${packet.body.title}: ${packet.body.text}`;
|
||||
let icon = await this._downloadIcon(packet);
|
||||
|
||||
// Repliable Notification
|
||||
if (packet.body.requestReplyId) {
|
||||
id = `${packet.body.id}|${packet.body.requestReplyId}`;
|
||||
action = {
|
||||
name: 'replyNotification',
|
||||
parameter: new GLib.Variant('(ssa{ss})', [
|
||||
packet.body.requestReplyId,
|
||||
'',
|
||||
{
|
||||
appName: packet.body.appName,
|
||||
title: packet.body.title,
|
||||
text: packet.body.text,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// Notification Actions
|
||||
if (packet.body.actions) {
|
||||
buttons = packet.body.actions.map(action => {
|
||||
return {
|
||||
label: action,
|
||||
action: 'activateNotification',
|
||||
parameter: new GLib.Variant('(ss)', [id, action]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Special case for Missed Calls
|
||||
if (packet.body.id.includes('MissedCall')) {
|
||||
title = packet.body.title;
|
||||
body = packet.body.text;
|
||||
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});
|
||||
|
||||
// Special case for SMS notifications
|
||||
} else if (_isSmsNotification(packet)) {
|
||||
title = packet.body.title;
|
||||
body = packet.body.text;
|
||||
action = {
|
||||
name: 'replySms',
|
||||
parameter: new GLib.Variant('s', packet.body.title),
|
||||
};
|
||||
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
|
||||
|
||||
// Special case where 'appName' is the same as 'title'
|
||||
} else if (packet.body.appName === packet.body.title) {
|
||||
body = packet.body.text;
|
||||
}
|
||||
|
||||
// Use the device icon if we still don't have one
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: this.device.icon_name});
|
||||
|
||||
// Show the notification
|
||||
this.device.showNotification({
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
action: action,
|
||||
buttons: buttons,
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote notifications be sent
|
||||
*/
|
||||
_requestNotifications() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification.request',
|
||||
body: {request: true},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that a local notification has been closed/dismissed.
|
||||
* TODO: kdeconnect-android doesn't handle incoming isCancel packets.
|
||||
*
|
||||
* @param {string} id - The local notification id
|
||||
*/
|
||||
withdrawNotification(id) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification',
|
||||
body: {
|
||||
isCancel: true,
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a remote notification.
|
||||
* TODO: ignore local notifications
|
||||
*
|
||||
* @param {string} id - The remote notification id
|
||||
*/
|
||||
closeNotification(id) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification.request',
|
||||
body: {cancel: id},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a notification sent with a requestReplyId UUID
|
||||
*
|
||||
* @param {string} uuid - The requestReplyId for the repliable notification
|
||||
* @param {string} message - The message to reply with
|
||||
* @param {Object} notification - The original notification packet
|
||||
*/
|
||||
replyNotification(uuid, message, notification) {
|
||||
// If this happens for some reason, things will explode
|
||||
if (!uuid)
|
||||
throw Error('Missing UUID');
|
||||
|
||||
// If the message has no content, open a dialog for the user to add one
|
||||
if (!message) {
|
||||
const dialog = new NotificationUI.ReplyDialog({
|
||||
device: this.device,
|
||||
uuid: uuid,
|
||||
notification: notification,
|
||||
plugin: this,
|
||||
});
|
||||
dialog.present();
|
||||
|
||||
// Otherwise just send the reply
|
||||
} else {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification.reply',
|
||||
body: {
|
||||
requestReplyId: uuid,
|
||||
message: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a remote notification action
|
||||
*
|
||||
* @param {string} id - The remote notification id
|
||||
* @param {string} action - The notification action (label)
|
||||
*/
|
||||
activateNotification(id, action) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.notification.action',
|
||||
body: {
|
||||
action: action,
|
||||
key: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.settings.disconnect(this._applicationsChangedId);
|
||||
|
||||
if (this._listener !== undefined) {
|
||||
this._listener.disconnect(this._notificationAddedId);
|
||||
this._listener = Components.release('notification');
|
||||
}
|
||||
|
||||
if (this._session !== undefined)
|
||||
this._session = Components.release('session');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Photo'),
|
||||
description: _('Request the paired device to take a photo and transfer it to this PC'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Photo',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.photo',
|
||||
'kdeconnect.photo.request',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.photo',
|
||||
'kdeconnect.photo.request',
|
||||
],
|
||||
actions: {
|
||||
photo: {
|
||||
label: _('Photo'),
|
||||
icon_name: 'camera-photo-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: ['kdeconnect.photo'],
|
||||
outgoing: ['kdeconnect.photo.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Photo Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/photo
|
||||
*
|
||||
* TODO: use Cheese?
|
||||
* check for /dev/video*
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectPhotoPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'photo');
|
||||
|
||||
// A reusable launcher for silence procs
|
||||
this._launcher = new Gio.SubprocessLauncher({
|
||||
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
|
||||
Gio.SubprocessFlags.STDERR_SILENCE),
|
||||
});
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.photo':
|
||||
this._receivePhoto(packet);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.photo.request':
|
||||
this._sendPhoto(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a directory set for storing files that exists.
|
||||
*
|
||||
* @return {string} An absolute directory path
|
||||
*/
|
||||
_ensureReceiveDirectory() {
|
||||
if (this._receiveDir !== undefined)
|
||||
return this._receiveDir;
|
||||
|
||||
// Ensure a directory is set
|
||||
this._receiveDir = this.settings.get_string('receive-directory');
|
||||
|
||||
if (this._receiveDir === '') {
|
||||
this._receiveDir = GLib.get_user_special_dir(
|
||||
GLib.UserDirectory.DIRECTORY_PICTURES
|
||||
);
|
||||
|
||||
// Fallback to ~/Pictures
|
||||
const homeDir = GLib.get_home_dir();
|
||||
|
||||
if (!this._receiveDir || this._receiveDir === homeDir) {
|
||||
this._receiveDir = GLib.build_filenamev([homeDir, 'Pictures']);
|
||||
this.settings.set_string('receive-directory', this._receiveDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!GLib.file_test(this._receiveDir, GLib.FileTest.IS_DIR))
|
||||
GLib.mkdir_with_parents(this._receiveDir, 448);
|
||||
|
||||
return this._receiveDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a GFile for @filename, while ensuring the directory exists and the
|
||||
* file is unique.
|
||||
*
|
||||
* @param {string} filename - A filename (eg. `image.jpg`)
|
||||
* @return {Gio.File} a file object
|
||||
*/
|
||||
_getFile(filename) {
|
||||
const dirpath = this._ensureReceiveDirectory();
|
||||
const basepath = GLib.build_filenamev([dirpath, filename]);
|
||||
let filepath = basepath;
|
||||
let copyNum = 0;
|
||||
|
||||
while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
|
||||
filepath = `${basepath} (${++copyNum})`;
|
||||
|
||||
return Gio.File.new_for_path(filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a photo taken by the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.photo`
|
||||
*/
|
||||
async _receivePhoto(packet) {
|
||||
let file, transfer;
|
||||
|
||||
try {
|
||||
// Remote device cancelled the photo operation
|
||||
if (packet.body.hasOwnProperty('cancel'))
|
||||
return;
|
||||
|
||||
// Open the target path and create a transfer
|
||||
file = this._getFile(packet.body.filename);
|
||||
|
||||
transfer = this.device.createTransfer();
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
// Open the photo if successful, delete on failure
|
||||
await transfer.start();
|
||||
|
||||
const uri = file.get_uri();
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (file)
|
||||
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a photo using the Webcam and return the path.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.photo.request`
|
||||
* @return {Promise<string>} A file path
|
||||
*/
|
||||
_takePhoto(packet) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const time = GLib.DateTime.new_now_local().format('%T');
|
||||
const path = GLib.build_filenamev([GLib.get_tmp_dir(), `${time}.jpg`]);
|
||||
const proc = this._launcher.spawnv([
|
||||
Config.FFMPEG_PATH,
|
||||
'-f', 'video4linux2',
|
||||
'-ss', '0:0:2',
|
||||
'-i', '/dev/video0',
|
||||
'-frames', '1',
|
||||
path,
|
||||
]);
|
||||
|
||||
proc.wait_check_async(null, (proc, res) => {
|
||||
try {
|
||||
proc.wait_check_finish(res);
|
||||
resolve(path);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo to the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.photo.request`
|
||||
*/
|
||||
async _sendPhoto(packet) {
|
||||
if (this.settings.get_boolean('share-camera'))
|
||||
return;
|
||||
|
||||
let file, transfer;
|
||||
|
||||
try {
|
||||
// Take a photo
|
||||
const path = await this._takePhoto();
|
||||
|
||||
if (path.startsWith('file://'))
|
||||
file = Gio.File.new_for_uri(path);
|
||||
else
|
||||
file = Gio.File.new_for_path(path);
|
||||
|
||||
// Create the transfer
|
||||
transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.photo',
|
||||
body: {
|
||||
filename: file.get_basename(),
|
||||
},
|
||||
}, file);
|
||||
|
||||
await transfer.start();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
if (transfer) {
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: _('Transfer Failed'),
|
||||
// TRANSLATORS: eg. Failed to send "photo.jpg" to Google Pixel
|
||||
body: _('Failed to send “%s” to %s').format(
|
||||
file.get_basename(),
|
||||
this.device.name
|
||||
),
|
||||
icon: new Gio.ThemedIcon({name: 'dialog-warning-symbolic'}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the remote device begin a photo operation.
|
||||
*/
|
||||
photo() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.photo.request',
|
||||
body: {},
|
||||
});
|
||||
}
|
||||
});
|
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Ping'),
|
||||
description: _('Send and receive pings'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Ping',
|
||||
incomingCapabilities: ['kdeconnect.ping'],
|
||||
outgoingCapabilities: ['kdeconnect.ping'],
|
||||
actions: {
|
||||
ping: {
|
||||
label: _('Ping'),
|
||||
icon_name: 'dialog-information-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.ping'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Ping Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/ping
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectPingPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'ping');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
// Notification
|
||||
const notif = {
|
||||
title: this.device.name,
|
||||
body: _('Ping'),
|
||||
icon: new Gio.ThemedIcon({name: `${this.device.icon_name}`}),
|
||||
};
|
||||
|
||||
if (packet.body.message) {
|
||||
// TRANSLATORS: An optional message accompanying a ping, rarely if ever used
|
||||
// eg. Ping: A message sent with ping
|
||||
notif.body = _('Ping: %s').format(packet.body.message);
|
||||
}
|
||||
|
||||
this.device.showNotification(notif);
|
||||
}
|
||||
|
||||
ping(message = '') {
|
||||
const packet = {
|
||||
type: 'kdeconnect.ping',
|
||||
body: {},
|
||||
};
|
||||
|
||||
if (message.length)
|
||||
packet.body.message = message;
|
||||
|
||||
this.device.sendPacket(packet);
|
||||
}
|
||||
});
|
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Presentation'),
|
||||
description: _('Use the paired device as a presenter'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Presenter',
|
||||
incomingCapabilities: ['kdeconnect.presenter'],
|
||||
outgoingCapabilities: [],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Presenter Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/presenter
|
||||
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/PresenterPlugin/
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectPresenterPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'presenter');
|
||||
|
||||
this._input = Components.acquire('input');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
if (packet.body.hasOwnProperty('dx')) {
|
||||
this._input.movePointer(
|
||||
packet.body.dx * 1000,
|
||||
packet.body.dy * 1000
|
||||
);
|
||||
} else if (packet.body.stop) {
|
||||
// Currently unsupported and unnecessary as we just re-use the mouse
|
||||
// pointer instead of showing an arbitrary window.
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._input !== undefined)
|
||||
this._input = Components.release('input');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,250 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Run Commands'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.RunCommand',
|
||||
description: _('Run commands on your paired device or let the device run predefined commands on this PC'),
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.runcommand',
|
||||
'kdeconnect.runcommand.request',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.runcommand',
|
||||
'kdeconnect.runcommand.request',
|
||||
],
|
||||
actions: {
|
||||
commands: {
|
||||
label: _('Commands'),
|
||||
icon_name: 'system-run-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: ['kdeconnect.runcommand'],
|
||||
outgoing: ['kdeconnect.runcommand.request'],
|
||||
},
|
||||
executeCommand: {
|
||||
label: _('Commands'),
|
||||
icon_name: 'system-run-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: ['kdeconnect.runcommand'],
|
||||
outgoing: ['kdeconnect.runcommand.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* RunCommand Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/remotecommands
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/runcommand
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectRunCommandPlugin',
|
||||
Properties: {
|
||||
'remote-commands': GObject.param_spec_variant(
|
||||
'remote-commands',
|
||||
'Remote Command List',
|
||||
'A list of the device\'s remote commands',
|
||||
new GLib.VariantType('a{sv}'),
|
||||
null,
|
||||
GObject.ParamFlags.READABLE
|
||||
),
|
||||
},
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'runcommand');
|
||||
|
||||
// Local Commands
|
||||
this._commandListChangedId = this.settings.connect(
|
||||
'changed::command-list',
|
||||
this._sendCommandList.bind(this)
|
||||
);
|
||||
|
||||
// We cache remote commands so they can be used in the settings even
|
||||
// when the device is offline.
|
||||
this._remote_commands = {};
|
||||
this.cacheProperties(['_remote_commands']);
|
||||
}
|
||||
|
||||
get remote_commands() {
|
||||
return this._remote_commands;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._sendCommandList();
|
||||
this._requestCommandList();
|
||||
this._handleCommandList(this.remote_commands);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._remote_commands = {};
|
||||
this.notify('remote-commands');
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
if (!this.device.connected)
|
||||
return;
|
||||
|
||||
this._sendCommandList();
|
||||
this._requestCommandList();
|
||||
this._handleCommandList(this.remote_commands);
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.runcommand':
|
||||
this._handleCommandList(packet.body.commandList);
|
||||
break;
|
||||
|
||||
case 'kdeconnect.runcommand.request':
|
||||
if (packet.body.hasOwnProperty('key'))
|
||||
this._handleCommand(packet.body.key);
|
||||
|
||||
else if (packet.body.hasOwnProperty('requestCommandList'))
|
||||
this._sendCommandList();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request to execute the local command with the UUID @key
|
||||
*
|
||||
* @param {string} key - The UUID of the local command
|
||||
*/
|
||||
_handleCommand(key) {
|
||||
try {
|
||||
const commands = this.settings.get_value('command-list');
|
||||
const commandList = commands.recursiveUnpack();
|
||||
|
||||
if (!commandList.hasOwnProperty(key)) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.IOErrorEnum.PERMISSION_DENIED,
|
||||
message: `Unknown command: ${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
this.device.launchProcess([
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
commandList[key].command,
|
||||
]);
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the response to a request for the remote command list. Remove the
|
||||
* command menu if there are no commands, otherwise amend the menu.
|
||||
*
|
||||
* @param {string|Object[]} commandList - A list of remote commands
|
||||
*/
|
||||
_handleCommandList(commandList) {
|
||||
// See: https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/1051
|
||||
if (typeof commandList === 'string') {
|
||||
try {
|
||||
commandList = JSON.parse(commandList);
|
||||
} catch (e) {
|
||||
commandList = {};
|
||||
}
|
||||
}
|
||||
|
||||
this._remote_commands = commandList;
|
||||
this.notify('remote-commands');
|
||||
|
||||
const commandEntries = Object.entries(this.remote_commands);
|
||||
|
||||
// If there are no commands, hide the menu by disabling the action
|
||||
this.device.lookup_action('commands').enabled = (commandEntries.length > 0);
|
||||
|
||||
// Commands Submenu
|
||||
const submenu = new Gio.Menu();
|
||||
|
||||
for (const [uuid, info] of commandEntries) {
|
||||
const item = new Gio.MenuItem();
|
||||
item.set_label(info.name);
|
||||
item.set_icon(
|
||||
new Gio.ThemedIcon({name: 'application-x-executable-symbolic'})
|
||||
);
|
||||
item.set_detailed_action(`device.executeCommand::${uuid}`);
|
||||
submenu.append_item(item);
|
||||
}
|
||||
|
||||
// Commands Item
|
||||
const item = new Gio.MenuItem();
|
||||
item.set_detailed_action('device.commands::menu');
|
||||
item.set_attribute_value(
|
||||
'hidden-when',
|
||||
new GLib.Variant('s', 'action-disabled')
|
||||
);
|
||||
item.set_icon(new Gio.ThemedIcon({name: 'system-run-symbolic'}));
|
||||
item.set_label(_('Commands'));
|
||||
item.set_submenu(submenu);
|
||||
|
||||
// If the submenu item is already present it will be replaced
|
||||
const menuActions = this.device.settings.get_strv('menu-actions');
|
||||
const index = menuActions.indexOf('commands');
|
||||
|
||||
if (index > -1) {
|
||||
this.device.removeMenuAction('commands');
|
||||
this.device.addMenuItem(item, index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request for the remote command list
|
||||
*/
|
||||
_requestCommandList() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.runcommand.request',
|
||||
body: {requestCommandList: true},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the local command list
|
||||
*/
|
||||
_sendCommandList() {
|
||||
const commands = this.settings.get_value('command-list').recursiveUnpack();
|
||||
const commandList = JSON.stringify(commands);
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.runcommand',
|
||||
body: {commandList: commandList},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder function for command action
|
||||
*/
|
||||
commands() {}
|
||||
|
||||
/**
|
||||
* Send a request to execute the remote command with the UUID @key
|
||||
*
|
||||
* @param {string} key - The UUID of the remote command
|
||||
*/
|
||||
executeCommand(key) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.runcommand.request',
|
||||
body: {key: key},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.settings.disconnect(this._commandListChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,565 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Config = imports.config;
|
||||
const Lan = imports.service.backends.lan;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('SFTP'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
|
||||
description: _('Browse the paired device filesystem'),
|
||||
incomingCapabilities: ['kdeconnect.sftp'],
|
||||
outgoingCapabilities: ['kdeconnect.sftp.request'],
|
||||
actions: {
|
||||
mount: {
|
||||
label: _('Mount'),
|
||||
icon_name: 'folder-remote-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: ['kdeconnect.sftp'],
|
||||
outgoing: ['kdeconnect.sftp.request'],
|
||||
},
|
||||
unmount: {
|
||||
label: _('Unmount'),
|
||||
icon_name: 'media-eject-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: ['kdeconnect.sftp'],
|
||||
outgoing: ['kdeconnect.sftp.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const MAX_MOUNT_DIRS = 12;
|
||||
|
||||
|
||||
/**
|
||||
* SFTP Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
|
||||
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectSFTPPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'sftp');
|
||||
|
||||
this._gmount = null;
|
||||
this._mounting = false;
|
||||
|
||||
// A reusable launcher for ssh processes
|
||||
this._launcher = new Gio.SubprocessLauncher({
|
||||
flags: (Gio.SubprocessFlags.STDOUT_PIPE |
|
||||
Gio.SubprocessFlags.STDERR_MERGE),
|
||||
});
|
||||
|
||||
// Watch the volume monitor
|
||||
this._volumeMonitor = Gio.VolumeMonitor.get();
|
||||
|
||||
this._mountAddedId = this._volumeMonitor.connect(
|
||||
'mount-added',
|
||||
this._onMountAdded.bind(this)
|
||||
);
|
||||
|
||||
this._mountRemovedId = this._volumeMonitor.connect(
|
||||
'mount-removed',
|
||||
this._onMountRemoved.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get gmount() {
|
||||
if (this._gmount === null && this.device.connected) {
|
||||
const host = this.device.channel.host;
|
||||
|
||||
const regex = new RegExp(
|
||||
`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
|
||||
);
|
||||
|
||||
for (const mount of this._volumeMonitor.get_mounts()) {
|
||||
const uri = mount.get_root().get_uri();
|
||||
|
||||
if (regex.test(uri)) {
|
||||
this._gmount = mount;
|
||||
this._addSubmenu(mount);
|
||||
this._addSymlink(mount);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._gmount;
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
// Only enable for Lan connections
|
||||
if (this.device.channel instanceof Lan.Channel) {
|
||||
if (this.settings.get_boolean('automount'))
|
||||
this.mount();
|
||||
} else {
|
||||
this.device.lookup_action('mount').enabled = false;
|
||||
this.device.lookup_action('unmount').enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.sftp':
|
||||
if (packet.body.hasOwnProperty('errorMessage'))
|
||||
this._handleError(packet);
|
||||
else
|
||||
this._handleMount(packet);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_onMountAdded(monitor, mount) {
|
||||
if (this._gmount !== null || !this.device.connected)
|
||||
return;
|
||||
|
||||
const host = this.device.channel.host;
|
||||
const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
|
||||
const uri = mount.get_root().get_uri();
|
||||
|
||||
if (!regex.test(uri))
|
||||
return;
|
||||
|
||||
this._gmount = mount;
|
||||
this._addSubmenu(mount);
|
||||
this._addSymlink(mount);
|
||||
}
|
||||
|
||||
_onMountRemoved(monitor, mount) {
|
||||
if (this.gmount !== mount)
|
||||
return;
|
||||
|
||||
this._gmount = null;
|
||||
this._removeSubmenu();
|
||||
}
|
||||
|
||||
async _listDirectories(mount) {
|
||||
const file = mount.get_root();
|
||||
|
||||
const iter = await new Promise((resolve, reject) => {
|
||||
file.enumerate_children_async(
|
||||
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.enumerate_children_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const infos = await new Promise((resolve, reject) => {
|
||||
iter.next_files_async(
|
||||
MAX_MOUNT_DIRS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(iter, res) => {
|
||||
try {
|
||||
resolve(iter.next_files_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
||||
|
||||
const directories = {};
|
||||
|
||||
for (const info of infos) {
|
||||
const name = info.get_name();
|
||||
directories[name] = `${file.get_uri()}${name}/`;
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
_onAskQuestion(op, message, choices) {
|
||||
op.reply(Gio.MountOperationResult.HANDLED);
|
||||
}
|
||||
|
||||
_onAskPassword(op, message, user, domain, flags) {
|
||||
op.reply(Gio.MountOperationResult.HANDLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error reported by the remote device.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
||||
*/
|
||||
_handleError(packet) {
|
||||
this.device.showNotification({
|
||||
id: 'sftp-error',
|
||||
title: _('%s reported an error').format(this.device.name),
|
||||
body: packet.body.errorMessage,
|
||||
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
|
||||
priority: Gio.NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the remote device using the provided information.
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
||||
*/
|
||||
async _handleMount(packet) {
|
||||
try {
|
||||
// Already mounted or mounting
|
||||
if (this.gmount !== null || this._mounting)
|
||||
return;
|
||||
|
||||
this._mounting = true;
|
||||
|
||||
// Ensure the private key is in the keyring
|
||||
await this._addPrivateKey();
|
||||
|
||||
// Create a new mount operation
|
||||
const op = new Gio.MountOperation({
|
||||
username: packet.body.user || null,
|
||||
password: packet.body.password || null,
|
||||
password_save: Gio.PasswordSave.NEVER,
|
||||
});
|
||||
|
||||
op.connect('ask-question', this._onAskQuestion);
|
||||
op.connect('ask-password', this._onAskPassword);
|
||||
|
||||
// This is the actual call to mount the device
|
||||
const host = this.device.channel.host;
|
||||
const uri = `sftp://${host}:${packet.body.port}/`;
|
||||
const file = Gio.File.new_for_uri(uri);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
file.mount_enclosing_volume(0, op, null, (file, res) => {
|
||||
try {
|
||||
resolve(file.mount_enclosing_volume_finish(res));
|
||||
} catch (e) {
|
||||
// Special case when the GMount didn't unmount properly
|
||||
// but is still on the same port and can be reused.
|
||||
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED)) {
|
||||
resolve(true);
|
||||
|
||||
// There's a good chance this is a host key verification
|
||||
// error; regardless we'll remove the key for security.
|
||||
} else {
|
||||
this._removeHostKey(host);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
} finally {
|
||||
this._mounting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add GSConnect's private key identity to the authentication agent so our
|
||||
* identity can be verified by Android during private key authentication.
|
||||
*
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
_addPrivateKey() {
|
||||
const ssh_add = this._launcher.spawnv([
|
||||
Config.SSHADD_PATH,
|
||||
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
|
||||
]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssh_add.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const result = proc.communicate_utf8_finish(res)[1].trim();
|
||||
|
||||
if (proc.get_exit_status() !== 0)
|
||||
debug(result, this.device.name);
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all host keys from ~/.ssh/known_hosts for @host in the port range
|
||||
* used by KDE Connect (1739-1764).
|
||||
*
|
||||
* @param {string} host - A hostname or IP address
|
||||
*/
|
||||
async _removeHostKey(host) {
|
||||
for (let port = 1739; port <= 1764; port++) {
|
||||
try {
|
||||
const ssh_keygen = this._launcher.spawnv([
|
||||
Config.SSHKEYGEN_PATH,
|
||||
'-R',
|
||||
`[${host}]:${port}`,
|
||||
]);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ssh_keygen.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const stdout = proc.communicate_utf8_finish(res)[1];
|
||||
const status = proc.get_exit_status();
|
||||
|
||||
if (status !== 0) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.io_error_from_errno(status),
|
||||
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Mount menu helpers
|
||||
*/
|
||||
_getUnmountSection() {
|
||||
if (this._unmountSection === undefined) {
|
||||
this._unmountSection = new Gio.Menu();
|
||||
|
||||
const unmountItem = new Gio.MenuItem();
|
||||
unmountItem.set_label(Metadata.actions.unmount.label);
|
||||
unmountItem.set_icon(new Gio.ThemedIcon({
|
||||
name: Metadata.actions.unmount.icon_name,
|
||||
}));
|
||||
unmountItem.set_detailed_action('device.unmount');
|
||||
this._unmountSection.append_item(unmountItem);
|
||||
}
|
||||
|
||||
return this._unmountSection;
|
||||
}
|
||||
|
||||
_getFilesMenuItem() {
|
||||
if (this._filesMenuItem === undefined) {
|
||||
// Files menu icon
|
||||
const emblem = new Gio.Emblem({
|
||||
icon: new Gio.ThemedIcon({name: 'emblem-default'}),
|
||||
});
|
||||
|
||||
const mountedIcon = new Gio.EmblemedIcon({
|
||||
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
|
||||
});
|
||||
mountedIcon.add_emblem(emblem);
|
||||
|
||||
// Files menu item
|
||||
this._filesMenuItem = new Gio.MenuItem();
|
||||
this._filesMenuItem.set_detailed_action('device.mount');
|
||||
this._filesMenuItem.set_icon(mountedIcon);
|
||||
this._filesMenuItem.set_label(_('Files'));
|
||||
}
|
||||
|
||||
return this._filesMenuItem;
|
||||
}
|
||||
|
||||
async _addSubmenu(mount) {
|
||||
try {
|
||||
const directories = await this._listDirectories(mount);
|
||||
|
||||
// Submenu sections
|
||||
const dirSection = new Gio.Menu();
|
||||
const unmountSection = this._getUnmountSection();
|
||||
|
||||
for (const [name, uri] of Object.entries(directories))
|
||||
dirSection.append(name, `device.openPath::${uri}`);
|
||||
|
||||
// Files submenu
|
||||
const filesSubmenu = new Gio.Menu();
|
||||
filesSubmenu.append_section(null, dirSection);
|
||||
filesSubmenu.append_section(null, unmountSection);
|
||||
|
||||
// Files menu item
|
||||
const filesMenuItem = this._getFilesMenuItem();
|
||||
filesMenuItem.set_submenu(filesSubmenu);
|
||||
|
||||
// Replace the existing menu item
|
||||
const index = this.device.removeMenuAction('device.mount');
|
||||
this.device.addMenuItem(filesMenuItem, index);
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
||||
debug(e, this.device.name);
|
||||
|
||||
// Reset to allow retrying
|
||||
this._gmount = null;
|
||||
}
|
||||
}
|
||||
|
||||
_removeSubmenu() {
|
||||
try {
|
||||
const index = this.device.removeMenuAction('device.mount');
|
||||
const action = this.device.lookup_action('mount');
|
||||
|
||||
if (action !== null) {
|
||||
this.device.addMenuAction(
|
||||
action,
|
||||
index,
|
||||
Metadata.actions.mount.label,
|
||||
Metadata.actions.mount.icon_name
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a symbolic link referring to the device by name
|
||||
*
|
||||
* @param {Gio.Mount} mount - A GMount to link to
|
||||
*/
|
||||
async _addSymlink(mount) {
|
||||
try {
|
||||
const by_name_dir = Gio.File.new_for_path(
|
||||
`${Config.RUNTIMEDIR}/by-name/`
|
||||
);
|
||||
|
||||
try {
|
||||
by_name_dir.make_directory_with_parents(null);
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Replace path separator with a Unicode lookalike:
|
||||
let safe_device_name = this.device.name.replace('/', '∕');
|
||||
|
||||
if (safe_device_name === '.')
|
||||
safe_device_name = '·';
|
||||
else if (safe_device_name === '..')
|
||||
safe_device_name = '··';
|
||||
|
||||
const link_target = mount.get_root().get_path();
|
||||
const link = Gio.File.new_for_path(
|
||||
`${by_name_dir.get_path()}/${safe_device_name}`
|
||||
);
|
||||
|
||||
// Check for and remove any existing stale link
|
||||
try {
|
||||
const link_stat = await new Promise((resolve, reject) => {
|
||||
link.query_info_async(
|
||||
'standard::symlink-target',
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(link, res) => {
|
||||
try {
|
||||
resolve(link.query_info_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (link_stat.get_symlink_target() === link_target)
|
||||
return;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
link.delete_async(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
(link, res) => {
|
||||
try {
|
||||
resolve(link.delete_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
|
||||
throw e;
|
||||
}
|
||||
|
||||
link.make_symbolic_link(link_target, null);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to mount the remote device
|
||||
*/
|
||||
mount() {
|
||||
if (this.gmount !== null)
|
||||
return;
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.sftp.request',
|
||||
body: {
|
||||
startBrowsing: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the menu items, unmount the filesystem, replace the mount item
|
||||
*/
|
||||
async unmount() {
|
||||
try {
|
||||
if (this.gmount === null)
|
||||
return;
|
||||
|
||||
this._removeSubmenu();
|
||||
this._mounting = false;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.gmount.unmount_with_operation(
|
||||
Gio.MountUnmountFlags.FORCE,
|
||||
new Gio.MountOperation(),
|
||||
null,
|
||||
(mount, res) => {
|
||||
try {
|
||||
resolve(mount.unmount_with_operation_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._volumeMonitor) {
|
||||
this._volumeMonitor.disconnect(this._mountAddedId);
|
||||
this._volumeMonitor.disconnect(this._mountRemovedId);
|
||||
this._volumeMonitor = null;
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,483 @@
|
||||
'use strict';
|
||||
|
||||
const GdkPixbuf = imports.gi.GdkPixbuf;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Share'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share',
|
||||
description: _('Share files and URLs between devices'),
|
||||
incomingCapabilities: ['kdeconnect.share.request'],
|
||||
outgoingCapabilities: ['kdeconnect.share.request'],
|
||||
actions: {
|
||||
share: {
|
||||
label: _('Share'),
|
||||
icon_name: 'send-to-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.share.request'],
|
||||
},
|
||||
shareFile: {
|
||||
label: _('Share File'),
|
||||
icon_name: 'document-send-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('(sb)'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.share.request'],
|
||||
},
|
||||
shareText: {
|
||||
label: _('Share Text'),
|
||||
icon_name: 'send-to-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.share.request'],
|
||||
},
|
||||
shareUri: {
|
||||
label: _('Share Link'),
|
||||
icon_name: 'send-to-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.share.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Share Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/share
|
||||
*
|
||||
* TODO: receiving 'text' TODO: Window with textview & 'Copy to Clipboard..
|
||||
* https://github.com/KDE/kdeconnect-kde/commit/28f11bd5c9a717fb9fbb3f02ddd6cea62021d055
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectSharePlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'share');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
// TODO: composite jobs (lastModified, numberOfFiles, totalPayloadSize)
|
||||
if (packet.body.hasOwnProperty('filename')) {
|
||||
if (this.settings.get_boolean('receive-files'))
|
||||
this._handleFile(packet);
|
||||
else
|
||||
this._refuseFile(packet);
|
||||
} else if (packet.body.hasOwnProperty('text')) {
|
||||
this._handleText(packet);
|
||||
} else if (packet.body.hasOwnProperty('url')) {
|
||||
this._handleUri(packet);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureReceiveDirectory() {
|
||||
let receiveDir = this.settings.get_string('receive-directory');
|
||||
|
||||
// Ensure a directory is set
|
||||
if (receiveDir.length === 0) {
|
||||
receiveDir = GLib.get_user_special_dir(
|
||||
GLib.UserDirectory.DIRECTORY_DOWNLOAD
|
||||
);
|
||||
|
||||
// Fallback to ~/Downloads
|
||||
const homeDir = GLib.get_home_dir();
|
||||
|
||||
if (!receiveDir || receiveDir === homeDir)
|
||||
receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
|
||||
|
||||
this.settings.set_string('receive-directory', receiveDir);
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR))
|
||||
GLib.mkdir_with_parents(receiveDir, 448);
|
||||
|
||||
return receiveDir;
|
||||
}
|
||||
|
||||
_getFile(filename) {
|
||||
const dirpath = this._ensureReceiveDirectory();
|
||||
const basepath = GLib.build_filenamev([dirpath, filename]);
|
||||
let filepath = basepath;
|
||||
let copyNum = 0;
|
||||
|
||||
while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
|
||||
filepath = `${basepath} (${++copyNum})`;
|
||||
|
||||
return Gio.File.new_for_path(filepath);
|
||||
}
|
||||
|
||||
_refuseFile(packet) {
|
||||
try {
|
||||
this.device.rejectTransfer(packet);
|
||||
|
||||
this.device.showNotification({
|
||||
id: `${Date.now()}`,
|
||||
title: _('Transfer Failed'),
|
||||
// TRANSLATORS: eg. Google Pixel is not allowed to upload files
|
||||
body: _('%s is not allowed to upload files').format(
|
||||
this.device.name
|
||||
),
|
||||
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleFile(packet) {
|
||||
try {
|
||||
const file = this._getFile(packet.body.filename);
|
||||
|
||||
// Create the transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile(packet, file);
|
||||
|
||||
// Notify that we're about to start the transfer
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: _('Transferring File'),
|
||||
// TRANSLATORS: eg. Receiving 'book.pdf' from Google Pixel
|
||||
body: _('Receiving “%s” from %s').format(
|
||||
packet.body.filename,
|
||||
this.device.name
|
||||
),
|
||||
buttons: [{
|
||||
label: _('Cancel'),
|
||||
action: 'cancelTransfer',
|
||||
parameter: new GLib.Variant('s', transfer.uuid),
|
||||
}],
|
||||
icon: new Gio.ThemedIcon({name: 'document-save-symbolic'}),
|
||||
});
|
||||
|
||||
// We'll show a notification (success or failure)
|
||||
let title, body, iconName;
|
||||
let buttons = [];
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
title = _('Transfer Successful');
|
||||
// TRANSLATORS: eg. Received 'book.pdf' from Google Pixel
|
||||
body = _('Received “%s” from %s').format(
|
||||
packet.body.filename,
|
||||
this.device.name
|
||||
);
|
||||
buttons = [
|
||||
{
|
||||
label: _('Open Folder'),
|
||||
action: 'openPath',
|
||||
parameter: new GLib.Variant('s', file.get_parent().get_uri()),
|
||||
},
|
||||
{
|
||||
label: _('Open File'),
|
||||
action: 'openPath',
|
||||
parameter: new GLib.Variant('s', file.get_uri()),
|
||||
},
|
||||
];
|
||||
iconName = 'document-save-symbolic';
|
||||
|
||||
if (packet.body.open) {
|
||||
const uri = file.get_uri();
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
}
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
title = _('Transfer Failed');
|
||||
// TRANSLATORS: eg. Failed to receive 'book.pdf' from Google Pixel
|
||||
body = _('Failed to receive “%s” from %s').format(
|
||||
packet.body.filename,
|
||||
this.device.name
|
||||
);
|
||||
iconName = 'dialog-warning-symbolic';
|
||||
|
||||
// Clean up the downloaded file on failure
|
||||
file.delete_async(GLib.PRIORITY_DEAFAULT, null, null);
|
||||
}
|
||||
|
||||
this.device.hideNotification(transfer.uuid);
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: title,
|
||||
body: body,
|
||||
buttons: buttons,
|
||||
icon: new Gio.ThemedIcon({name: iconName}),
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
_handleUri(packet) {
|
||||
const uri = packet.body.url;
|
||||
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||||
}
|
||||
|
||||
_handleText(packet) {
|
||||
const dialog = new Gtk.MessageDialog({
|
||||
text: _('Text Shared By %s').format(this.device.name),
|
||||
secondary_text: URI.linkify(packet.body.text),
|
||||
secondary_use_markup: true,
|
||||
buttons: Gtk.ButtonsType.CLOSE,
|
||||
});
|
||||
dialog.message_area.get_children()[1].selectable = true;
|
||||
dialog.set_keep_above(true);
|
||||
dialog.connect('response', (dialog) => dialog.destroy());
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the file chooser dialog for selecting a file or inputing a URI.
|
||||
*/
|
||||
share() {
|
||||
const dialog = new FileChooserDialog(this.device);
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share local file path or URI
|
||||
*
|
||||
* @param {string} path - Local file path or URI
|
||||
* @param {boolean} open - Whether the file should be opened after transfer
|
||||
*/
|
||||
async shareFile(path, open = false) {
|
||||
try {
|
||||
let file = null;
|
||||
|
||||
if (path.includes('://'))
|
||||
file = Gio.File.new_for_uri(path);
|
||||
else
|
||||
file = Gio.File.new_for_path(path);
|
||||
|
||||
// Create the transfer
|
||||
const transfer = this.device.createTransfer();
|
||||
|
||||
transfer.addFile({
|
||||
type: 'kdeconnect.share.request',
|
||||
body: {
|
||||
filename: file.get_basename(),
|
||||
open: open,
|
||||
},
|
||||
}, file);
|
||||
|
||||
// Notify that we're about to start the transfer
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: _('Transferring File'),
|
||||
// TRANSLATORS: eg. Sending 'book.pdf' to Google Pixel
|
||||
body: _('Sending “%s” to %s').format(
|
||||
file.get_basename(),
|
||||
this.device.name
|
||||
),
|
||||
buttons: [{
|
||||
label: _('Cancel'),
|
||||
action: 'cancelTransfer',
|
||||
parameter: new GLib.Variant('s', transfer.uuid),
|
||||
}],
|
||||
icon: new Gio.ThemedIcon({name: 'document-send-symbolic'}),
|
||||
});
|
||||
|
||||
// We'll show a notification (success or failure)
|
||||
let title, body, iconName;
|
||||
|
||||
try {
|
||||
await transfer.start();
|
||||
|
||||
title = _('Transfer Successful');
|
||||
// TRANSLATORS: eg. Sent "book.pdf" to Google Pixel
|
||||
body = _('Sent “%s” to %s').format(
|
||||
file.get_basename(),
|
||||
this.device.name
|
||||
);
|
||||
iconName = 'document-send-symbolic';
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
|
||||
title = _('Transfer Failed');
|
||||
// TRANSLATORS: eg. Failed to send "book.pdf" to Google Pixel
|
||||
body = _('Failed to send “%s” to %s').format(
|
||||
file.get_basename(),
|
||||
this.device.name
|
||||
);
|
||||
iconName = 'dialog-warning-symbolic';
|
||||
}
|
||||
|
||||
this.device.hideNotification(transfer.uuid);
|
||||
this.device.showNotification({
|
||||
id: transfer.uuid,
|
||||
title: title,
|
||||
body: body,
|
||||
icon: new Gio.ThemedIcon({name: iconName}),
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a string of text. Remote behaviour is undefined.
|
||||
*
|
||||
* @param {string} text - A string of unicode text
|
||||
*/
|
||||
shareText(text) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.share.request',
|
||||
body: {text: text},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a URI. Generally the remote device opens it with the scheme default
|
||||
*
|
||||
* @param {string} uri - A URI to share
|
||||
*/
|
||||
shareUri(uri) {
|
||||
if (GLib.uri_parse_scheme(uri) === 'file') {
|
||||
this.shareFile(uri);
|
||||
return;
|
||||
}
|
||||
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.share.request',
|
||||
body: {url: uri},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/** A simple FileChooserDialog for sharing files */
|
||||
var FileChooserDialog = GObject.registerClass({
|
||||
GTypeName: 'GSConnectShareFileChooserDialog',
|
||||
}, class FileChooserDialog extends Gtk.FileChooserDialog {
|
||||
|
||||
_init(device) {
|
||||
super._init({
|
||||
// TRANSLATORS: eg. Send files to Google Pixel
|
||||
title: _('Send files to %s').format(device.name),
|
||||
select_multiple: true,
|
||||
extra_widget: new Gtk.CheckButton({
|
||||
// TRANSLATORS: Mark the file to be opened once completed
|
||||
label: _('Open when done'),
|
||||
visible: true,
|
||||
}),
|
||||
use_preview_label: false,
|
||||
});
|
||||
|
||||
this.device = device;
|
||||
|
||||
// Align checkbox with sidebar
|
||||
const box = this.get_content_area().get_children()[0].get_children()[0];
|
||||
const paned = box.get_children()[0];
|
||||
paned.bind_property(
|
||||
'position',
|
||||
this.extra_widget,
|
||||
'margin-left',
|
||||
GObject.BindingFlags.SYNC_CREATE
|
||||
);
|
||||
|
||||
// Preview Widget
|
||||
this.preview_widget = new Gtk.Image();
|
||||
this.preview_widget_active = false;
|
||||
this.connect('update-preview', this._onUpdatePreview);
|
||||
|
||||
// URI entry
|
||||
this._uriEntry = new Gtk.Entry({
|
||||
placeholder_text: 'https://',
|
||||
hexpand: true,
|
||||
visible: true,
|
||||
});
|
||||
this._uriEntry.connect('activate', this._sendLink.bind(this));
|
||||
|
||||
// URI/File toggle
|
||||
this._uriButton = new Gtk.ToggleButton({
|
||||
image: new Gtk.Image({
|
||||
icon_name: 'web-browser-symbolic',
|
||||
pixel_size: 16,
|
||||
}),
|
||||
valign: Gtk.Align.CENTER,
|
||||
// TRANSLATORS: eg. Send a link to Google Pixel
|
||||
tooltip_text: _('Send a link to %s').format(device.name),
|
||||
visible: true,
|
||||
});
|
||||
this._uriButton.connect('toggled', this._onUriButtonToggled.bind(this));
|
||||
|
||||
this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
|
||||
const sendButton = this.add_button(_('Send'), Gtk.ResponseType.OK);
|
||||
sendButton.connect('clicked', this._sendLink.bind(this));
|
||||
|
||||
this.get_header_bar().pack_end(this._uriButton);
|
||||
this.set_default_response(Gtk.ResponseType.OK);
|
||||
}
|
||||
|
||||
_onUpdatePreview(chooser) {
|
||||
try {
|
||||
const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
|
||||
chooser.get_preview_filename(),
|
||||
chooser.get_scale_factor() * 128,
|
||||
-1
|
||||
);
|
||||
chooser.preview_widget.pixbuf = pixbuf;
|
||||
chooser.preview_widget.visible = true;
|
||||
chooser.preview_widget_active = true;
|
||||
} catch (e) {
|
||||
chooser.preview_widget.visible = false;
|
||||
chooser.preview_widget_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
_onUriButtonToggled(button) {
|
||||
const header = this.get_header_bar();
|
||||
|
||||
// Show the URL entry
|
||||
if (button.active) {
|
||||
this.extra_widget.sensitive = false;
|
||||
header.set_custom_title(this._uriEntry);
|
||||
this.set_response_sensitive(Gtk.ResponseType.OK, true);
|
||||
|
||||
// Hide the URL entry
|
||||
} else {
|
||||
header.set_custom_title(null);
|
||||
this.set_response_sensitive(
|
||||
Gtk.ResponseType.OK,
|
||||
this.get_uris().length > 1
|
||||
);
|
||||
this.extra_widget.sensitive = true;
|
||||
}
|
||||
}
|
||||
|
||||
_sendLink(widget) {
|
||||
if (this._uriButton.active && this._uriEntry.text.length)
|
||||
this.response(1);
|
||||
}
|
||||
|
||||
vfunc_response(response_id) {
|
||||
if (response_id === Gtk.ResponseType.OK) {
|
||||
for (const uri of this.get_uris()) {
|
||||
const parameter = new GLib.Variant(
|
||||
'(sb)',
|
||||
[uri, this.extra_widget.active]
|
||||
);
|
||||
this.device.activate_action('shareFile', parameter);
|
||||
}
|
||||
} else if (response_id === 1) {
|
||||
const parameter = new GLib.Variant('s', this._uriEntry.text);
|
||||
this.device.activate_action('shareUri', parameter);
|
||||
}
|
||||
|
||||
this.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,527 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const PluginBase = imports.service.plugin;
|
||||
const LegacyMessaging = imports.service.ui.legacyMessaging;
|
||||
const Messaging = imports.service.ui.messaging;
|
||||
const URI = imports.service.utils.uri;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('SMS'),
|
||||
description: _('Send and read SMS of the paired device and be notified of new SMS'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SMS',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.sms.messages',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.sms.request',
|
||||
'kdeconnect.sms.request_conversation',
|
||||
'kdeconnect.sms.request_conversations',
|
||||
],
|
||||
actions: {
|
||||
// SMS Actions
|
||||
sms: {
|
||||
label: _('Messaging'),
|
||||
icon_name: 'sms-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
uriSms: {
|
||||
label: _('New SMS (URI)'),
|
||||
icon_name: 'sms-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
replySms: {
|
||||
label: _('Reply SMS'),
|
||||
icon_name: 'sms-symbolic',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
sendMessage: {
|
||||
label: _('Send Message'),
|
||||
icon_name: 'sms-send',
|
||||
|
||||
parameter_type: new GLib.VariantType('(aa{sv})'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
sendSms: {
|
||||
label: _('Send SMS'),
|
||||
icon_name: 'sms-send',
|
||||
|
||||
parameter_type: new GLib.VariantType('(ss)'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
shareSms: {
|
||||
label: _('Share SMS'),
|
||||
icon_name: 'sms-send',
|
||||
|
||||
parameter_type: new GLib.VariantType('s'),
|
||||
incoming: [],
|
||||
outgoing: ['kdeconnect.sms.request'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* SMS Message event type. Currently all events are TEXT_MESSAGE.
|
||||
*
|
||||
* TEXT_MESSAGE: Has a "body" field which contains pure, human-readable text
|
||||
*/
|
||||
var MessageEvent = {
|
||||
TEXT_MESSAGE: 0x1,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* SMS Message status. READ/UNREAD match the 'read' field from the Android App
|
||||
* message packet.
|
||||
*
|
||||
* UNREAD: A message not marked as read
|
||||
* READ: A message marked as read
|
||||
*/
|
||||
var MessageStatus = {
|
||||
UNREAD: 0,
|
||||
READ: 1,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* SMS Message direction. IN/OUT match the 'type' field from the Android App
|
||||
* message packet.
|
||||
*
|
||||
* See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
|
||||
*
|
||||
* IN: An incoming message
|
||||
* OUT: An outgoing message
|
||||
*/
|
||||
var MessageBox = {
|
||||
ALL: 0,
|
||||
INBOX: 1,
|
||||
SENT: 2,
|
||||
DRAFT: 3,
|
||||
OUTBOX: 4,
|
||||
FAILED: 5,
|
||||
QUEUED: 6,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* SMS Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sms
|
||||
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SMSPlugin/
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectSMSPlugin',
|
||||
Properties: {
|
||||
'threads': GObject.param_spec_variant(
|
||||
'threads',
|
||||
'Conversation List',
|
||||
'A list of threads',
|
||||
new GLib.VariantType('aa{sv}'),
|
||||
null,
|
||||
GObject.ParamFlags.READABLE
|
||||
),
|
||||
},
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'sms');
|
||||
|
||||
this.cacheProperties(['_threads']);
|
||||
}
|
||||
|
||||
get threads() {
|
||||
if (this._threads === undefined)
|
||||
this._threads = {};
|
||||
|
||||
return this._threads;
|
||||
}
|
||||
|
||||
get window() {
|
||||
if (this.settings.get_boolean('legacy-sms')) {
|
||||
return new LegacyMessaging.Dialog({
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._window === undefined) {
|
||||
this._window = new Messaging.Window({
|
||||
application: Gio.Application.get_default(),
|
||||
device: this.device,
|
||||
plugin: this,
|
||||
});
|
||||
|
||||
this._window.connect('destroy', () => {
|
||||
this._window = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return this._window;
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this._threads = {};
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
cacheLoaded() {
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
this._requestConversations();
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.sms.messages':
|
||||
this._handleMessages(packet.body.messages);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a digest of threads.
|
||||
*
|
||||
* @param {Object[]} messages - A list of message objects
|
||||
* @param {string[]} thread_ids - A list of thread IDs as strings
|
||||
*/
|
||||
_handleDigest(messages, thread_ids) {
|
||||
// Prune threads
|
||||
for (const thread_id of Object.keys(this.threads)) {
|
||||
if (!thread_ids.includes(thread_id))
|
||||
delete this.threads[thread_id];
|
||||
}
|
||||
|
||||
// Request each new or newer thread
|
||||
for (let i = 0, len = messages.length; i < len; i++) {
|
||||
const message = messages[i];
|
||||
const cache = this.threads[message.thread_id];
|
||||
|
||||
if (cache === undefined) {
|
||||
this._requestConversation(message.thread_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this message is marked read, mark the rest as read
|
||||
if (message.read === MessageStatus.READ) {
|
||||
for (const msg of cache)
|
||||
msg.read = MessageStatus.READ;
|
||||
}
|
||||
|
||||
// If we don't have a thread for this message or it's newer
|
||||
// than the last message in the cache, request the thread
|
||||
if (!cache.length || cache[cache.length - 1].date < message.date)
|
||||
this._requestConversation(message.thread_id);
|
||||
}
|
||||
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new single message
|
||||
*
|
||||
* @param {Object} message - A message object
|
||||
*/
|
||||
_handleMessage(message) {
|
||||
let conversation = null;
|
||||
|
||||
// If the window is open, try and find an active conversation
|
||||
if (this._window)
|
||||
conversation = this._window.getConversationForMessage(message);
|
||||
|
||||
// If there's an active conversation, we should log the message now
|
||||
if (conversation)
|
||||
conversation.logNext(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a conversation (thread of messages) and sort them
|
||||
*
|
||||
* @param {Object[]} thread - A list of sms message objects from a thread
|
||||
*/
|
||||
_handleThread(thread) {
|
||||
// If there are no addresses this will cause major problems...
|
||||
if (!thread[0].addresses || !thread[0].addresses[0])
|
||||
return;
|
||||
|
||||
const thread_id = thread[0].thread_id;
|
||||
const cache = this.threads[thread_id] || [];
|
||||
|
||||
// Handle each message
|
||||
for (let i = 0, len = thread.length; i < len; i++) {
|
||||
const message = thread[i];
|
||||
|
||||
// TODO: We only cache messages of a known MessageBox since we
|
||||
// have no reliable way to determine its direction, let alone
|
||||
// what to do with it.
|
||||
if (message.type < 0 || message.type > 6)
|
||||
continue;
|
||||
|
||||
// If the message exists, just update it
|
||||
const cacheMessage = cache.find(m => m.date === message.date);
|
||||
|
||||
if (cacheMessage) {
|
||||
Object.assign(cacheMessage, message);
|
||||
} else {
|
||||
cache.push(message);
|
||||
this._handleMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the thread by ascending date and notify
|
||||
this.threads[thread_id] = cache.sort((a, b) => a.date - b.date);
|
||||
this.notify('threads');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a response to telephony.request_conversation(s)
|
||||
*
|
||||
* @param {Object[]} messages - A list of sms message objects
|
||||
*/
|
||||
_handleMessages(messages) {
|
||||
try {
|
||||
// If messages is empty there's nothing to do...
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
|
||||
const thread_ids = [];
|
||||
|
||||
// Perform some modification of the messages
|
||||
for (let i = 0, len = messages.length; i < len; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
// COERCION: thread_id's to strings
|
||||
message.thread_id = `${message.thread_id}`;
|
||||
thread_ids.push(message.thread_id);
|
||||
|
||||
// TODO: Remove bogus `insert-address-token` entries
|
||||
let a = message.addresses.length;
|
||||
|
||||
while (a--) {
|
||||
if (message.addresses[a].address === undefined ||
|
||||
message.addresses[a].address === 'insert-address-token')
|
||||
message.addresses.splice(a, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's multiple thread_id's it's a summary of threads
|
||||
if (thread_ids.some(id => id !== thread_ids[0]))
|
||||
this._handleDigest(messages, thread_ids);
|
||||
|
||||
// Otherwise this is single thread or new message
|
||||
else
|
||||
this._handleThread(messages);
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of messages from a single thread.
|
||||
*
|
||||
* @param {number} thread_id - The id of the thread to request
|
||||
*/
|
||||
_requestConversation(thread_id) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.sms.request_conversation',
|
||||
body: {
|
||||
threadID: thread_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of the last message in each unarchived thread.
|
||||
*/
|
||||
_requestConversations() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.sms.request_conversations',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A notification action for replying to SMS messages (or missed calls).
|
||||
*
|
||||
* @param {string} hint - Could be either a contact name or phone number
|
||||
*/
|
||||
replySms(hint) {
|
||||
this.window.present();
|
||||
// FIXME: causes problems now that non-numeric addresses are allowed
|
||||
// this.window.address = hint.toPhoneNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SMS message
|
||||
*
|
||||
* @param {string} phoneNumber - The phone number to send the message to
|
||||
* @param {string} messageBody - The message to send
|
||||
*/
|
||||
sendSms(phoneNumber, messageBody) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.sms.request',
|
||||
body: {
|
||||
sendSms: true,
|
||||
phoneNumber: phoneNumber,
|
||||
messageBody: messageBody,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*
|
||||
* @param {Object[]} addresses - A list of address objects
|
||||
* @param {string} messageBody - The message text
|
||||
* @param {number} [event] - An event bitmask
|
||||
* @param {boolean} [forceSms] - Whether to force SMS
|
||||
* @param {number} [subId] - The SIM card to use
|
||||
*/
|
||||
sendMessage(addresses, messageBody, event = 1, forceSms = false, subId = undefined) {
|
||||
// TODO: waiting on support in kdeconnect-android
|
||||
// if (this._version === 1) {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.sms.request',
|
||||
body: {
|
||||
sendSms: true,
|
||||
phoneNumber: addresses[0].address,
|
||||
messageBody: messageBody,
|
||||
},
|
||||
});
|
||||
// } else if (this._version === 2) {
|
||||
// this.device.sendPacket({
|
||||
// type: 'kdeconnect.sms.request',
|
||||
// body: {
|
||||
// version: 2,
|
||||
// addresses: addresses,
|
||||
// messageBody: messageBody,
|
||||
// forceSms: forceSms,
|
||||
// sub_id: subId
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a text content by SMS message. This is used by the WebExtension to
|
||||
* share URLs from the browser, but could be used to initiate sharing of any
|
||||
* text content.
|
||||
*
|
||||
* @param {string} url - The link to be shared
|
||||
*/
|
||||
shareSms(url) {
|
||||
// Legacy Mode
|
||||
if (this.settings.get_boolean('legacy-sms')) {
|
||||
const window = this.window;
|
||||
window.present();
|
||||
window.setMessage(url);
|
||||
|
||||
// If there are active threads, show the chooser dialog
|
||||
} else if (Object.values(this.threads).length > 0) {
|
||||
const window = new Messaging.ConversationChooser({
|
||||
application: Gio.Application.get_default(),
|
||||
device: this.device,
|
||||
message: url,
|
||||
plugin: this,
|
||||
});
|
||||
|
||||
window.present();
|
||||
|
||||
// Otherwise show the window and wait for a contact to be chosen
|
||||
} else {
|
||||
this.window.present();
|
||||
this.window.setMessage(url, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open and present the messaging window
|
||||
*/
|
||||
sms() {
|
||||
this.window.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the sms: URI scheme handler
|
||||
*
|
||||
* @param {string} uri - The URI the handle (sms:|sms://|sms:///)
|
||||
*/
|
||||
uriSms(uri) {
|
||||
try {
|
||||
uri = new URI.SmsURI(uri);
|
||||
|
||||
// Lookup contacts
|
||||
const addresses = uri.recipients.map(number => {
|
||||
return {address: number.toPhoneNumber()};
|
||||
});
|
||||
const contacts = this.device.contacts.lookupAddresses(addresses);
|
||||
|
||||
// Present the window and show the conversation
|
||||
const window = this.window;
|
||||
window.present();
|
||||
window.setContacts(contacts);
|
||||
|
||||
// Set the outgoing message if the uri has a body variable
|
||||
if (uri.body)
|
||||
window.setMessage(uri.body);
|
||||
} catch (e) {
|
||||
debug(e, `${this.device.name}: "${uri}"`);
|
||||
}
|
||||
}
|
||||
|
||||
_threadHasAddress(thread, addressObj) {
|
||||
const number = addressObj.address.toPhoneNumber();
|
||||
|
||||
for (const taddressObj of thread[0].addresses) {
|
||||
const tnumber = taddressObj.address.toPhoneNumber();
|
||||
|
||||
if (number.endsWith(tnumber) || tnumber.endsWith(number))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a thread_id in @smsPlugin for @addresses.
|
||||
*
|
||||
* @param {Object[]} addresses - a list of address objects
|
||||
* @return {string|null} a thread ID
|
||||
*/
|
||||
getThreadIdForAddresses(addresses = []) {
|
||||
const threads = Object.values(this.threads);
|
||||
|
||||
for (const thread of threads) {
|
||||
if (addresses.length !== thread[0].addresses.length)
|
||||
continue;
|
||||
|
||||
if (addresses.every(addressObj => this._threadHasAddress(thread, addressObj)))
|
||||
return thread[0].thread_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._window !== undefined)
|
||||
this._window.destroy();
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const Config = imports.config;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('System Volume'),
|
||||
description: _('Enable the paired device to control the system volume'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SystemVolume',
|
||||
incomingCapabilities: ['kdeconnect.systemvolume.request'],
|
||||
outgoingCapabilities: ['kdeconnect.systemvolume'],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* SystemVolume Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/systemvolume
|
||||
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SystemvolumePlugin/
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectSystemVolumePlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'systemvolume');
|
||||
|
||||
// Cache stream properties
|
||||
this._cache = new WeakMap();
|
||||
|
||||
// Connect to the mixer
|
||||
try {
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
|
||||
this._streamChangedId = this._mixer.connect(
|
||||
'stream-changed',
|
||||
this._sendSink.bind(this)
|
||||
);
|
||||
|
||||
this._outputAddedId = this._mixer.connect(
|
||||
'output-added',
|
||||
this._sendSinkList.bind(this)
|
||||
);
|
||||
|
||||
this._outputRemovedId = this._mixer.connect(
|
||||
'output-removed',
|
||||
this._sendSinkList.bind(this)
|
||||
);
|
||||
|
||||
// Modify the error to redirect to the wiki
|
||||
} catch (e) {
|
||||
e.name = _('PulseAudio not found');
|
||||
e.url = `${Config.PACKAGE_URL}/wiki/Error#pulseaudio-not-found`;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (true) {
|
||||
case packet.body.hasOwnProperty('requestSinks'):
|
||||
this._sendSinkList();
|
||||
break;
|
||||
|
||||
case packet.body.hasOwnProperty('name'):
|
||||
this._changeSink(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
connected() {
|
||||
super.connected();
|
||||
|
||||
this._sendSinkList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request to change an output
|
||||
*
|
||||
* @param {Core.Packet} packet - a `kdeconnect.systemvolume.request`
|
||||
*/
|
||||
_changeSink(packet) {
|
||||
let stream;
|
||||
|
||||
for (const sink of this._mixer.get_sinks()) {
|
||||
if (sink.name === packet.body.name) {
|
||||
stream = sink;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// No sink with the given name
|
||||
if (stream === undefined) {
|
||||
this._sendSinkList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a cache and store volume and mute states if changed
|
||||
const cache = this._cache.get(stream) || {};
|
||||
|
||||
if (packet.body.hasOwnProperty('muted')) {
|
||||
cache.muted = packet.body.muted;
|
||||
this._cache.set(stream, cache);
|
||||
stream.change_is_muted(packet.body.muted);
|
||||
}
|
||||
|
||||
if (packet.body.hasOwnProperty('volume')) {
|
||||
cache.volume = packet.body.volume;
|
||||
this._cache.set(stream, cache);
|
||||
stream.volume = packet.body.volume;
|
||||
stream.push_volume();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cache for @stream
|
||||
*
|
||||
* @param {Gvc.MixerStream} stream - The stream to cache
|
||||
* @return {Object} The updated cache object
|
||||
*/
|
||||
_updateCache(stream) {
|
||||
const state = {
|
||||
name: stream.name,
|
||||
description: stream.display_name,
|
||||
muted: stream.is_muted,
|
||||
volume: stream.volume,
|
||||
maxVolume: this._mixer.get_vol_max_norm(),
|
||||
};
|
||||
|
||||
this._cache.set(stream, state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the state of a local sink
|
||||
*
|
||||
* @param {Gvc.MixerControl} mixer - The mixer that owns the stream
|
||||
* @param {number} id - The Id of the stream that changed
|
||||
*/
|
||||
_sendSink(mixer, id) {
|
||||
// Avoid starving the packet channel when fading
|
||||
if (this._mixer.fading)
|
||||
return;
|
||||
|
||||
// Check the cache
|
||||
const stream = this._mixer.lookup_stream_id(id);
|
||||
const cache = this._cache.get(stream) || {};
|
||||
|
||||
// If the port has changed we have to send the whole list to update the
|
||||
// display name
|
||||
if (!cache.display_name || cache.display_name !== stream.display_name) {
|
||||
this._sendSinkList();
|
||||
return;
|
||||
}
|
||||
|
||||
// If only volume and/or mute are set, send a single update
|
||||
if (cache.volume !== stream.volume || cache.muted !== stream.is_muted) {
|
||||
// Update the cache
|
||||
const state = this._updateCache(stream);
|
||||
|
||||
// Send the stream update
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.systemvolume',
|
||||
body: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a list of local sinks
|
||||
*/
|
||||
_sendSinkList() {
|
||||
const sinkList = this._mixer.get_sinks().map(sink => {
|
||||
return this._updateCache(sink);
|
||||
});
|
||||
|
||||
// Send the sinkList
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.systemvolume',
|
||||
body: {
|
||||
sinkList: sinkList,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mixer !== undefined) {
|
||||
this._mixer.disconnect(this._streamChangedId);
|
||||
this._mixer.disconnect(this._outputAddedId);
|
||||
this._mixer.disconnect(this._outputRemovedId);
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const GdkPixbuf = imports.gi.GdkPixbuf;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Components = imports.service.components;
|
||||
const PluginBase = imports.service.plugin;
|
||||
|
||||
|
||||
var Metadata = {
|
||||
label: _('Telephony'),
|
||||
description: _('Be notified about calls and adjust system volume during ringing/ongoing calls'),
|
||||
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony',
|
||||
incomingCapabilities: [
|
||||
'kdeconnect.telephony',
|
||||
],
|
||||
outgoingCapabilities: [
|
||||
'kdeconnect.telephony.request',
|
||||
'kdeconnect.telephony.request_mute',
|
||||
],
|
||||
actions: {
|
||||
muteCall: {
|
||||
// TRANSLATORS: Silence the actively ringing call
|
||||
label: _('Mute Call'),
|
||||
icon_name: 'audio-volume-muted-symbolic',
|
||||
|
||||
parameter_type: null,
|
||||
incoming: ['kdeconnect.telephony'],
|
||||
outgoing: ['kdeconnect.telephony.request_mute'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Telephony Plugin
|
||||
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/telephony
|
||||
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/TelephonyPlugin
|
||||
*/
|
||||
var Plugin = GObject.registerClass({
|
||||
GTypeName: 'GSConnectTelephonyPlugin',
|
||||
}, class Plugin extends PluginBase.Plugin {
|
||||
|
||||
_init(device) {
|
||||
super._init(device, 'telephony');
|
||||
|
||||
// Neither of these are crucial for the plugin to work
|
||||
this._mpris = Components.acquire('mpris');
|
||||
this._mixer = Components.acquire('pulseaudio');
|
||||
}
|
||||
|
||||
handlePacket(packet) {
|
||||
switch (packet.type) {
|
||||
case 'kdeconnect.telephony':
|
||||
this._handleEvent(packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change volume, microphone and media player state in response to an
|
||||
* incoming or answered call.
|
||||
*
|
||||
* @param {string} eventType - 'ringing' or 'talking'
|
||||
*/
|
||||
_setMediaState(eventType) {
|
||||
// Mixer Volume
|
||||
if (this._mixer !== undefined) {
|
||||
switch (this.settings.get_string(`${eventType}-volume`)) {
|
||||
case 'restore':
|
||||
this._mixer.restore();
|
||||
break;
|
||||
|
||||
case 'lower':
|
||||
this._mixer.lowerVolume();
|
||||
break;
|
||||
|
||||
case 'mute':
|
||||
this._mixer.muteVolume();
|
||||
break;
|
||||
}
|
||||
|
||||
if (eventType === 'talking' && this.settings.get_boolean('talking-microphone'))
|
||||
this._mixer.muteMicrophone();
|
||||
}
|
||||
|
||||
// Media Playback
|
||||
if (this._mpris && this.settings.get_boolean(`${eventType}-pause`))
|
||||
this._mpris.pauseAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore volume, microphone and media player state (if changed), making
|
||||
* sure to unpause before raising volume.
|
||||
*
|
||||
* TODO: there's a possibility we might revert a media/mixer state set for
|
||||
* another device.
|
||||
*/
|
||||
_restoreMediaState() {
|
||||
// Media Playback
|
||||
if (this._mpris)
|
||||
this._mpris.unpauseAll();
|
||||
|
||||
// Mixer Volume
|
||||
if (this._mixer)
|
||||
this._mixer.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a Gdk.Pixbuf from base64 encoded data
|
||||
*
|
||||
* @param {string} data - Base64 encoded JPEG data
|
||||
* @return {Gdk.Pixbuf|null} A contact photo
|
||||
*/
|
||||
_getThumbnailPixbuf(data) {
|
||||
const loader = new GdkPixbuf.PixbufLoader();
|
||||
|
||||
try {
|
||||
data = GLib.base64_decode(data);
|
||||
loader.write(data);
|
||||
loader.close();
|
||||
} catch (e) {
|
||||
debug(e, this.device.name);
|
||||
}
|
||||
|
||||
return loader.get_pixbuf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a telephony event (ringing, talking), showing or hiding a
|
||||
* notification and possibly adjusting the media/mixer state.
|
||||
*
|
||||
* @param {Core.Packet} packet - A `kdeconnect.telephony`
|
||||
*/
|
||||
_handleEvent(packet) {
|
||||
// Only handle 'ringing' or 'talking' events; leave the notification
|
||||
// plugin to handle 'missedCall' since they're often repliable
|
||||
if (!['ringing', 'talking'].includes(packet.body.event))
|
||||
return;
|
||||
|
||||
// This is the end of a telephony event
|
||||
if (packet.body.isCancel)
|
||||
this._cancelEvent(packet);
|
||||
else
|
||||
this._notifyEvent(packet);
|
||||
}
|
||||
|
||||
_cancelEvent(packet) {
|
||||
// Ensure we have a sender
|
||||
// TRANSLATORS: No name or phone number
|
||||
let sender = _('Unknown Contact');
|
||||
|
||||
if (packet.body.contactName)
|
||||
sender = packet.body.contactName;
|
||||
else if (packet.body.phoneNumber)
|
||||
sender = packet.body.phoneNumber;
|
||||
|
||||
this.device.hideNotification(`${packet.body.event}|${sender}`);
|
||||
this._restoreMediaState();
|
||||
}
|
||||
|
||||
_notifyEvent(packet) {
|
||||
let body;
|
||||
let buttons = [];
|
||||
let icon = null;
|
||||
let priority = Gio.NotificationPriority.NORMAL;
|
||||
|
||||
// Ensure we have a sender
|
||||
// TRANSLATORS: No name or phone number
|
||||
let sender = _('Unknown Contact');
|
||||
|
||||
if (packet.body.contactName)
|
||||
sender = packet.body.contactName;
|
||||
else if (packet.body.phoneNumber)
|
||||
sender = packet.body.phoneNumber;
|
||||
|
||||
// If there's a photo, use it as the notification icon
|
||||
if (packet.body.phoneThumbnail)
|
||||
icon = this._getThumbnailPixbuf(packet.body.phoneThumbnail);
|
||||
|
||||
if (icon === null)
|
||||
icon = new Gio.ThemedIcon({name: 'call-start-symbolic'});
|
||||
|
||||
// Notify based based on the event type
|
||||
if (packet.body.event === 'ringing') {
|
||||
this._setMediaState('ringing');
|
||||
|
||||
// TRANSLATORS: The phone is ringing
|
||||
body = _('Incoming call');
|
||||
buttons = [{
|
||||
action: 'muteCall',
|
||||
// TRANSLATORS: Silence the actively ringing call
|
||||
label: _('Mute'),
|
||||
parameter: null,
|
||||
}];
|
||||
priority = Gio.NotificationPriority.URGENT;
|
||||
}
|
||||
|
||||
if (packet.body.event === 'talking') {
|
||||
this.device.hideNotification(`ringing|${sender}`);
|
||||
this._setMediaState('talking');
|
||||
|
||||
// TRANSLATORS: A phone call is active
|
||||
body = _('Ongoing call');
|
||||
}
|
||||
|
||||
this.device.showNotification({
|
||||
id: `${packet.body.event}|${sender}`,
|
||||
title: sender,
|
||||
body: body,
|
||||
icon: icon,
|
||||
priority: priority,
|
||||
buttons: buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence an incoming call and restore the previous mixer/media state, if
|
||||
* applicable.
|
||||
*/
|
||||
muteCall() {
|
||||
this.device.sendPacket({
|
||||
type: 'kdeconnect.telephony.request_mute',
|
||||
body: {},
|
||||
});
|
||||
|
||||
this._restoreMediaState();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._mixer !== undefined)
|
||||
this._mixer = Components.release('pulseaudio');
|
||||
|
||||
if (this._mpris !== undefined)
|
||||
this._mpris = Components.release('mpris');
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user