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

View File

@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
export default {
PACKAGE_VERSION: 57,
PACKAGE_URL: 'https://github.com/GSConnect/gnome-shell-extension-gsconnect',
PACKAGE_BUGREPORT: 'https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/new',
PACKAGE_DATADIR: '/usr/local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io',
PACKAGE_LOCALEDIR: '/usr/local/share/locale',
GSETTINGS_SCHEMA_DIR: '/usr/local/share/glib-2.0/schemas',
GNOME_SHELL_LIBDIR: '/usr/local/lib64',
APP_ID: 'org.gnome.Shell.Extensions.GSConnect',
APP_PATH: '/org/gnome/Shell/Extensions/GSConnect',
IS_USER: false,
// External binary paths
OPENSSL_PATH: 'openssl',
SSHADD_PATH: 'ssh-add',
SSHKEYGEN_PATH: 'ssh-keygen',
FFMPEG_PATH: 'ffmpeg',
};

View File

@@ -0,0 +1,407 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
// Bootstrap
import {
Extension,
gettext as _,
ngettext
} from 'resource:///org/gnome/shell/extensions/extension.js';
import Config from './config.js';
import * as Clipboard from './shell/clipboard.js';
import * as Device from './shell/device.js';
import * as Keybindings from './shell/keybindings.js';
import * as Notification from './shell/notification.js';
import * as Input from './shell/input.js';
import * as Utils from './shell/utils.js';
import * as Remote from './utils/remote.js';
import setup from './utils/setup.js';
const QuickSettingsMenu = Main.panel.statusArea.quickSettings;
/**
* A System Indicator used as the hub for spawning device indicators and
* indicating that the extension is active when there are none.
*/
const ServiceToggle = GObject.registerClass({
GTypeName: 'GSConnectServiceIndicator',
}, class ServiceToggle extends QuickSettings.QuickMenuToggle {
_init() {
super._init({
title: 'GSConnect',
toggleMode: true,
});
this.set({iconName: 'org.gnome.Shell.Extensions.GSConnect-symbolic'});
// Set QuickMenuToggle header.
this.menu.setHeader('org.gnome.Shell.Extensions.GSConnect-symbolic', 'GSConnect',
_('Sync between your devices'));
this._menus = {};
this._keybindings = new Keybindings.Manager();
// GSettings
this.settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect',
null
),
path: '/org/gnome/shell/extensions/gsconnect/',
});
// Bind the toggle to enabled key
this.settings.bind('enabled',
this, 'checked',
Gio.SettingsBindFlags.DEFAULT);
this._enabledId = this.settings.connect(
'changed::enabled',
this._onEnabledChanged.bind(this)
);
this._panelModeId = this.settings.connect(
'changed::show-indicators',
this._sync.bind(this)
);
// Service Proxy
this.service = new Remote.Service();
this._deviceAddedId = this.service.connect(
'device-added',
this._onDeviceAdded.bind(this)
);
this._deviceRemovedId = this.service.connect(
'device-removed',
this._onDeviceRemoved.bind(this)
);
this._serviceChangedId = this.service.connect(
'notify::active',
this._onServiceChanged.bind(this)
);
// Service Menu -> Devices Section
this.deviceSection = new PopupMenu.PopupMenuSection();
this.deviceSection.actor.add_style_class_name('gsconnect-device-section');
this.settings.bind(
'show-indicators',
this.deviceSection.actor,
'visible',
Gio.SettingsBindFlags.INVERT_BOOLEAN
);
this.menu.addMenuItem(this.deviceSection);
// Service Menu -> Separator
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
// Service Menu -> "Mobile Settings"
this.menu.addSettingsAction(
_('Mobile Settings'),
'org.gnome.Shell.Extensions.GSConnect.Preferences.desktop');
// Prime the service
this._initService();
}
async _initService() {
try {
if (this.settings.get_boolean('enabled'))
await this.service.start();
else
await this.service.reload();
} catch (e) {
logError(e, 'GSConnect');
}
}
_sync() {
const available = this.service.devices.filter(device => {
return (device.connected && device.paired);
});
const panelMode = this.settings.get_boolean('show-indicators');
// Hide status indicator if in Panel mode or no devices are available
serviceIndicator._indicator.visible = (!panelMode && available.length);
// Show device indicators in Panel mode if available
for (const device of this.service.devices) {
const isAvailable = available.includes(device);
const indicator = Main.panel.statusArea[device.g_object_path];
indicator.visible = panelMode && isAvailable;
const menu = this._menus[device.g_object_path];
menu.actor.visible = !panelMode && isAvailable;
menu._title.actor.visible = !panelMode && isAvailable;
}
// Set subtitle on Quick Settings tile
if (available.length === 1) {
this.subtitle = available[0].name;
} else if (available.length > 1) {
// TRANSLATORS: %d is the number of devices connected
this.subtitle = ngettext(
'%d Connected',
'%d Connected',
available.length
).format(available.length);
} else {
this.subtitle = null;
}
}
_onDeviceChanged(device, changed, invalidated) {
try {
const properties = changed.deepUnpack();
if (properties.hasOwnProperty('Connected') ||
properties.hasOwnProperty('Paired'))
this._sync();
} catch (e) {
logError(e, 'GSConnect');
}
}
_onDeviceAdded(service, device) {
try {
// Device Indicator
const indicator = new Device.Indicator({device: device});
Main.panel.addToStatusArea(device.g_object_path, indicator);
// Device Menu
const menu = new Device.Menu({
device: device,
menu_type: 'list',
});
this._menus[device.g_object_path] = menu;
this.deviceSection.addMenuItem(menu);
// Device Settings
device.settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect.Device',
true
),
path: `/org/gnome/shell/extensions/gsconnect/device/${device.id}/`,
});
// Keyboard Shortcuts
device.__keybindingsChangedId = device.settings.connect(
'changed::keybindings',
this._onDeviceKeybindingsChanged.bind(this, device)
);
this._onDeviceKeybindingsChanged(device);
// Watch the for status changes
device.__deviceChangedId = device.connect(
'g-properties-changed',
this._onDeviceChanged.bind(this)
);
this._sync();
} catch (e) {
logError(e, 'GSConnect');
}
}
_onDeviceRemoved(service, device, sync = true) {
try {
// Stop watching for status changes
if (device.__deviceChangedId)
device.disconnect(device.__deviceChangedId);
// Release keybindings
if (device.__keybindingsChangedId) {
device.settings.disconnect(device.__keybindingsChangedId);
device._keybindings.map(id => this._keybindings.remove(id));
}
// Destroy the indicator
Main.panel.statusArea[device.g_object_path].destroy();
// Destroy the menu
this._menus[device.g_object_path].destroy();
delete this._menus[device.g_object_path];
if (sync)
this._sync();
} catch (e) {
logError(e, 'GSConnect');
}
}
_onDeviceKeybindingsChanged(device) {
try {
// Reset any existing keybindings
if (device.hasOwnProperty('_keybindings'))
device._keybindings.map(id => this._keybindings.remove(id));
device._keybindings = [];
// Get the keybindings
const keybindings = device.settings.get_value('keybindings').deepUnpack();
// Apply the keybindings
for (const [action, accelerator] of Object.entries(keybindings)) {
const [, name, parameter] = Gio.Action.parse_detailed_name(action);
const actionId = this._keybindings.add(
accelerator,
() => device.action_group.activate_action(name, parameter)
);
if (actionId !== 0)
device._keybindings.push(actionId);
}
} catch (e) {
logError(e, 'GSConnect');
}
}
async _onEnabledChanged(settings, key) {
try {
if (this.settings.get_boolean('enabled'))
await this.service.start();
else
await this.service.stop();
} catch (e) {
logError(e, 'GSConnect');
}
}
async _onServiceChanged(service, pspec) {
try {
// If it's enabled, we should try to restart now
if (this.settings.get_boolean('enabled'))
await this.service.start();
} catch (e) {
logError(e, 'GSConnect');
}
}
destroy() {
// Unhook from Remote.Service
if (this.service) {
this.service.disconnect(this._serviceChangedId);
this.service.disconnect(this._deviceAddedId);
this.service.disconnect(this._deviceRemovedId);
for (const device of this.service.devices)
this._onDeviceRemoved(this.service, device, false);
if (!this.settings.get_boolean('keep-alive-when-locked'))
this.service.stop();
this.service.destroy();
}
// Disconnect any keybindings
this._keybindings.destroy();
// Disconnect from any GSettings changes
this.settings.disconnect(this._enabledId);
this.settings.disconnect(this._panelModeId);
this.settings.run_dispose();
// Destroy the PanelMenu.SystemIndicator actors
this.menu.destroy();
super.destroy();
}
});
const ServiceIndicator = GObject.registerClass(
class ServiceIndicator extends QuickSettings.SystemIndicator {
_init() {
super._init();
// Create the icon for the indicator
this._indicator = this._addIndicator();
this._indicator.icon_name = 'org.gnome.Shell.Extensions.GSConnect-symbolic';
// Hide the indicator by default
this._indicator.visible = false;
// Create the toggle menu and associate it with the indicator
this.quickSettingsItems.push(new ServiceToggle());
// Add the indicator to the panel and the toggle to the menu
QuickSettingsMenu.addExternalIndicator(this);
}
destroy() {
// Set enabled state to false to kill the service on destroy
this.quickSettingsItems.forEach(item => item.destroy());
// Destroy the indicator
this._indicator.destroy();
super.destroy();
}
});
let serviceIndicator = null;
export default class GSConnectExtension extends Extension {
lockscreenInput = null;
constructor(metadata) {
super(metadata);
setup(this.path);
// If installed as a user extension, this checks the permissions
// on certain critical files in the extension directory
// to ensure that they have the executable bit set,
// and makes them executable if not. Some packaging methods
// (particularly GitHub Actions artifacts) automatically remove
// executable bits from all contents, presumably for security.
Utils.ensurePermissions();
// If installed as a user extension, this will install the Desktop entry,
// DBus and systemd service files necessary for DBus activation and
// GNotifications. Since there's no uninit()/uninstall() hook for extensions
// and they're only used *by* GSConnect, they should be okay to leave.
Utils.installService();
// These modify the notification source for GSConnect's GNotifications and
// need to be active even when the extension is disabled (eg. lock screen).
// Since they *only* affect notifications from GSConnect, it should be okay
// to leave them applied.
Notification.patchGSConnectNotificationSource();
Notification.patchGtkNotificationDaemon();
// This watches for the service to start and exports a custom clipboard
// portal for use on Wayland
Clipboard.watchService();
}
enable() {
serviceIndicator = new ServiceIndicator();
Notification.patchGtkNotificationSources();
this.lockscreenInput = new Input.LockscreenRemoteAccess();
this.lockscreenInput.patchInhibitor();
}
disable() {
serviceIndicator.destroy();
serviceIndicator = null;
Notification.unpatchGtkNotificationSources();
if (this.lockscreenInput) {
this.lockscreenInput.unpatchInhibitor();
this.lockscreenInput = null;
}
}
}

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env -S gjs -m
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
// -*- mode: js; -*-
import Gdk from 'gi://Gdk?version=3.0';
import 'gi://GdkPixbuf?version=2.0';
import Gio from 'gi://Gio?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import GObject from 'gi://GObject?version=2.0';
import Gtk from 'gi://Gtk?version=3.0';
import system from 'system';
import './preferences/init.js';
import {Window} from './preferences/service.js';
import Config from './config.js';
import('gi://GioUnix?version=2.0').catch(() => {}); // Set version for optional dependency
/**
* Class representing the GSConnect service daemon.
*/
const Preferences = GObject.registerClass({
GTypeName: 'GSConnectPreferences',
Implements: [Gio.ActionGroup],
}, class Preferences extends Gtk.Application {
_init() {
super._init({
application_id: 'org.gnome.Shell.Extensions.GSConnect.Preferences',
resource_base_path: '/org/gnome/Shell/Extensions/GSConnect',
});
GLib.set_prgname('gsconnect-preferences');
GLib.set_application_name(_('GSConnect Preferences'));
}
vfunc_activate() {
if (this._window === undefined) {
this._window = new Window({
application: this,
});
}
this._window.present();
}
vfunc_startup() {
super.vfunc_startup();
// Init some resources
const provider = new Gtk.CssProvider();
provider.load_from_resource(`${Config.APP_PATH}/application.css`);
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
);
const actions = [
['refresh', null],
['connect', GLib.VariantType.new('s')],
];
for (const [name, type] of actions) {
const action = new Gio.SimpleAction({
name: name,
parameter_type: type,
});
this.add_action(action);
}
}
vfunc_activate_action(action_name, parameter) {
try {
const paramArray = [];
if (parameter instanceof GLib.Variant)
paramArray[0] = parameter;
this.get_dbus_connection().call(
'org.gnome.Shell.Extensions.GSConnect',
'/org/gnome/Shell/Extensions/GSConnect',
'org.freedesktop.Application',
'ActivateAction',
GLib.Variant.new('(sava{sv})', [action_name, paramArray, {}]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
null
);
} catch (e) {
logError(e);
}
}
});
await (new Preferences()).runAsync([system.programInvocationName].concat(ARGV));

View File

@@ -0,0 +1,11 @@
{
"_generated": "Generated by SweetTooth, do not edit",
"description": "GSConnect is a complete implementation of KDE Connect especially for GNOME Shell with Nautilus, Chrome and Firefox integration. It does not rely on the KDE Connect desktop application and will not work with it installed.\n\nKDE Connect allows devices to securely share content like notifications or files and other features like SMS messaging and remote control. The KDE Connect team has applications for Linux, BSD, Android, Sailfish, iOS, macOS and Windows.\n\nPlease report issues on Github!",
"name": "GSConnect",
"shell-version": [
"46"
],
"url": "https://github.com/GSConnect/gnome-shell-extension-gsconnect/wiki",
"uuid": "gsconnect@andyholmes.github.io",
"version": 57
}

View File

@@ -0,0 +1,212 @@
# SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
nautilus-gsconnect.py - A Nautilus extension for sending files via GSConnect.
A great deal of credit and appreciation is owed to the indicator-kdeconnect
developers for the sister Python script 'kdeconnect-send-nautilus.py':
https://github.com/Bajoja/indicator-kdeconnect/blob/master/data/extensions/kdeconnect-send-nautilus.py
"""
import gettext
import os.path
import sys
import gi
gi.require_version("Gio", "2.0")
gi.require_version("GLib", "2.0")
gi.require_version("GObject", "2.0")
from gi.repository import Gio, GLib, GObject
# Host application detection
#
# Nemo seems to reliably identify itself as 'nemo' in argv[0], so we
# can test for that. Nautilus detection is less reliable, so don't try.
# See https://github.com/linuxmint/nemo-extensions/issues/330
if "nemo" in sys.argv[0].lower():
# Host runtime is nemo-python
gi.require_version("Nemo", "3.0")
from gi.repository import Nemo as FileManager
else:
# Otherwise, just assume it's nautilus-python
from gi.repository import Nautilus as FileManager
SERVICE_NAME = "org.gnome.Shell.Extensions.GSConnect"
SERVICE_PATH = "/org/gnome/Shell/Extensions/GSConnect"
# Init gettext translations
LOCALE_DIR = os.path.join(
GLib.get_user_data_dir(),
"gnome-shell",
"extensions",
"gsconnect@andyholmes.github.io",
"locale",
)
if not os.path.exists(LOCALE_DIR):
LOCALE_DIR = None
try:
i18n = gettext.translation(SERVICE_NAME, localedir=LOCALE_DIR)
_ = i18n.gettext
except (IOError, OSError) as e:
print("GSConnect: {0}".format(str(e)))
i18n = gettext.translation(
SERVICE_NAME, localedir=LOCALE_DIR, fallback=True
)
_ = i18n.gettext
class GSConnectShareExtension(GObject.Object, FileManager.MenuProvider):
"""A context menu for sending files via GSConnect."""
def __init__(self):
"""Initialize the DBus ObjectManager."""
GObject.Object.__init__(self)
self.devices = {}
Gio.DBusProxy.new_for_bus(
Gio.BusType.SESSION,
Gio.DBusProxyFlags.DO_NOT_AUTO_START,
None,
SERVICE_NAME,
SERVICE_PATH,
"org.freedesktop.DBus.ObjectManager",
None,
self._init_async,
None,
)
def _init_async(self, proxy, res, user_data):
proxy = proxy.new_for_bus_finish(res)
proxy.connect("notify::g-name-owner", self._on_name_owner_changed)
proxy.connect("g-signal", self._on_g_signal)
self._on_name_owner_changed(proxy, None)
def _on_g_signal(self, proxy, sender_name, signal_name, parameters):
# Wait until the service is ready
if proxy.props.g_name_owner is None:
return
objects = parameters.unpack()
if signal_name == "InterfacesAdded":
for object_path, props in objects.items():
props = props["org.gnome.Shell.Extensions.GSConnect.Device"]
self.devices[object_path] = (
props["Name"],
Gio.DBusActionGroup.get(
proxy.get_connection(), SERVICE_NAME, object_path
),
)
elif signal_name == "InterfacesRemoved":
for object_path in objects:
try:
del self.devices[object_path]
except KeyError:
pass
def _on_name_owner_changed(self, proxy, pspec):
# Wait until the service is ready
if proxy.props.g_name_owner is None:
self.devices = {}
else:
proxy.call(
"GetManagedObjects",
None,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
None,
self._get_managed_objects,
None,
)
def _get_managed_objects(self, proxy, res, user_data):
objects = proxy.call_finish(res)[0]
for object_path, props in objects.items():
props = props["org.gnome.Shell.Extensions.GSConnect.Device"]
if not props:
continue
self.devices[object_path] = (
props["Name"],
Gio.DBusActionGroup.get(
proxy.get_connection(), SERVICE_NAME, object_path
),
)
def send_files(self, menu, files, action_group):
"""Send *files* to *device_id*."""
for file in files:
variant = GLib.Variant("(sb)", (file.get_uri(), False))
action_group.activate_action("shareFile", variant)
def get_file_items(self, *args):
"""Return a list of select files to be sent."""
# 'args' will depend on the Nautilus API version.
# * Nautilus 4.0:
# `[files: List[Nautilus.FileInfo]]`
# * Nautilus 3.0:
# `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]`
files = args[-1]
# Only accept regular files
for uri in files:
if uri.get_uri_scheme() != "file" or uri.is_directory():
return ()
# Enumerate capable devices
devices = []
for name, action_group in self.devices.values():
if action_group.get_action_enabled("shareFile"):
devices.append([name, action_group])
# No capable devices; don't show menu entry
if not devices:
return ()
# If there's exactly 1 device, no submenu
if len(devices) == 1:
name, action_group = devices[0]
menu = FileManager.MenuItem(
name="GSConnectShareExtension::Device" + name,
# TRANSLATORS: Send to <device_name>, for file manager
# context menu
label=_("Send to %s") % name,
)
menu.connect("activate", self.send_files, files, action_group)
else:
# Context Menu Item
menu = FileManager.MenuItem(
name="GSConnectShareExtension::Devices",
label=_("Send To Mobile Device"),
)
# Context Submenu
submenu = FileManager.Menu()
menu.set_submenu(submenu)
# Context Submenu Items
for name, action_group in devices:
item = FileManager.MenuItem(
name="GSConnectShareExtension::Device" + name, label=name
)
item.connect("activate", self.send_files, files, action_group)
submenu.append_item(item)
return (menu,)

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import setup, {setupGettext} from '../utils/setup.js';
// Bootstrap
setup(GLib.path_get_dirname(GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0])));
setupGettext();

View File

@@ -0,0 +1,314 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
/*
* A list of modifier keysyms we ignore
*/
const _MODIFIERS = [
Gdk.KEY_Alt_L,
Gdk.KEY_Alt_R,
Gdk.KEY_Caps_Lock,
Gdk.KEY_Control_L,
Gdk.KEY_Control_R,
Gdk.KEY_Meta_L,
Gdk.KEY_Meta_R,
Gdk.KEY_Num_Lock,
Gdk.KEY_Shift_L,
Gdk.KEY_Shift_R,
Gdk.KEY_Super_L,
Gdk.KEY_Super_R,
];
/**
* Response enum for ShortcutChooserDialog
*/
export const ResponseType = {
CANCEL: Gtk.ResponseType.CANCEL,
SET: Gtk.ResponseType.APPLY,
UNSET: 2,
};
/**
* A simplified version of the shortcut editor from GNOME Control Center
*/
export const ShortcutChooserDialog = GObject.registerClass({
GTypeName: 'GSConnectPreferencesShortcutEditor',
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-shortcut-editor.ui',
Children: [
'cancel-button', 'set-button',
'stack', 'summary-label',
'shortcut-label', 'conflict-label',
],
}, class ShortcutChooserDialog extends Gtk.Dialog {
_init(params) {
super._init({
transient_for: Gio.Application.get_default().get_active_window(),
use_header_bar: true,
});
this._seat = Gdk.Display.get_default().get_default_seat();
// Current accelerator or %null
this.accelerator = params.accelerator;
// TRANSLATORS: Summary of a keyboard shortcut function
// Example: Enter a new shortcut to change Messaging
this.summary = _('Enter a new shortcut to change <b>%s</b>').format(
params.summary
);
}
get accelerator() {
return this.shortcut_label.accelerator;
}
set accelerator(value) {
this.shortcut_label.accelerator = value;
}
get summary() {
return this.summary_label.label;
}
set summary(value) {
this.summary_label.label = value;
}
vfunc_key_press_event(event) {
let keyvalLower = Gdk.keyval_to_lower(event.keyval);
let realMask = event.state & Gtk.accelerator_get_default_mod_mask();
// TODO: Critical: 'WIDGET_REALIZED_FOR_EVENT (widget, event)' failed
if (_MODIFIERS.includes(keyvalLower))
return true;
// Normalize Tab
if (keyvalLower === Gdk.KEY_ISO_Left_Tab)
keyvalLower = Gdk.KEY_Tab;
// Put shift back if it changed the case of the key, not otherwise.
if (keyvalLower !== event.keyval)
realMask |= Gdk.ModifierType.SHIFT_MASK;
// HACK: we don't want to use SysRq as a keybinding (but we do want
// Alt+Print), so we avoid translation from Alt+Print to SysRq
if (keyvalLower === Gdk.KEY_Sys_Req && (realMask & Gdk.ModifierType.MOD1_MASK) !== 0)
keyvalLower = Gdk.KEY_Print;
// A single Escape press cancels the editing
if (realMask === 0 && keyvalLower === Gdk.KEY_Escape) {
this.response(ResponseType.CANCEL);
return false;
}
// Backspace disables the current shortcut
if (realMask === 0 && keyvalLower === Gdk.KEY_BackSpace) {
this.response(ResponseType.UNSET);
return false;
}
// CapsLock isn't supported as a keybinding modifier, so keep it from
// confusing us
realMask &= ~Gdk.ModifierType.LOCK_MASK;
if (keyvalLower !== 0 && realMask !== 0) {
this._ungrab();
// Set the accelerator property/label
this.accelerator = Gtk.accelerator_name(keyvalLower, realMask);
// TRANSLATORS: When a keyboard shortcut is unavailable
// Example: [Ctrl]+[S] is already being used
this.conflict_label.label = _('%s is already being used').format(
Gtk.accelerator_get_label(keyvalLower, realMask)
);
// Show Cancel button and switch to confirm/conflict page
this.cancel_button.visible = true;
this.stack.visible_child_name = 'confirm';
this._check();
}
return true;
}
async _check() {
try {
const available = await checkAccelerator(this.accelerator);
this.set_button.visible = available;
this.conflict_label.visible = !available;
} catch (e) {
logError(e);
this.response(ResponseType.CANCEL);
}
}
_grab() {
const success = this._seat.grab(
this.get_window(),
Gdk.SeatCapabilities.KEYBOARD,
true, // owner_events
null, // cursor
null, // event
null
);
if (success !== Gdk.GrabStatus.SUCCESS)
return this.response(ResponseType.CANCEL);
if (!this._seat.get_keyboard() && !this._seat.get_pointer())
return this.response(ResponseType.CANCEL);
this.grab_add();
}
_ungrab() {
this._seat.ungrab();
this.grab_remove();
}
// Override to use our own ungrab process
response(response_id) {
this.hide();
this._ungrab();
return super.response(response_id);
}
// Override with a non-blocking version of Gtk.Dialog.run()
run() {
this.show();
// Wait a bit before attempting grab
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
this._grab();
return GLib.SOURCE_REMOVE;
});
}
});
/**
* Check the availability of an accelerator using GNOME Shell's DBus interface.
*
* @param {string} accelerator - An accelerator
* @param {number} [modeFlags] - Mode Flags
* @param {number} [grabFlags] - Grab Flags
* @param {boolean} %true if available, %false on error or unavailable
*/
export async function checkAccelerator(accelerator, modeFlags = 0, grabFlags = 0) {
try {
let result = false;
// Try to grab the accelerator
const action = await new Promise((resolve, reject) => {
Gio.DBus.session.call(
'org.gnome.Shell',
'/org/gnome/Shell',
'org.gnome.Shell',
'GrabAccelerator',
new GLib.Variant('(suu)', [accelerator, modeFlags, grabFlags]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
// If successful, use the result of ungrabbing as our return
if (action !== 0) {
result = await new Promise((resolve, reject) => {
Gio.DBus.session.call(
'org.gnome.Shell',
'/org/gnome/Shell',
'org.gnome.Shell',
'UngrabAccelerator',
new GLib.Variant('(u)', [action]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
res = connection.call_finish(res);
resolve(res.deepUnpack()[0]);
} catch (e) {
reject(e);
}
}
);
});
}
return result;
} catch (e) {
logError(e);
return false;
}
}
/**
* Show a dialog to get a keyboard shortcut from a user.
*
* @param {string} summary - A description of the keybinding's function
* @param {string} accelerator - An accelerator as taken by Gtk.ShortcutLabel
* @return {string} An accelerator or %null if it should be unset.
*/
export async function getAccelerator(summary, accelerator = null) {
try {
const dialog = new ShortcutChooserDialog({
summary: summary,
accelerator: accelerator,
});
accelerator = await new Promise((resolve, reject) => {
dialog.connect('response', (dialog, response) => {
switch (response) {
case ResponseType.SET:
accelerator = dialog.accelerator;
break;
case ResponseType.UNSET:
accelerator = null;
break;
case ResponseType.CANCEL:
// leave the accelerator as passed in
break;
}
dialog.destroy();
resolve(accelerator);
});
dialog.run();
});
return accelerator;
} catch (e) {
logError(e);
return accelerator;
}
}

View File

@@ -0,0 +1,648 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import system from 'system';
import Config from '../config.js';
import {Panel, rowSeparators} from './device.js';
import {Service} from '../utils/remote.js';
/*
* Header for support logs
*/
const LOG_HEADER = new GLib.Bytes(`
GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'})
GJS: ${system.version}
Session: ${GLib.getenv('XDG_SESSION_TYPE')}
OS: ${GLib.get_os_info('PRETTY_NAME')}
--------------------------------------------------------------------------------
`);
/**
* Generate a support log.
*
* @param {string} time - Start time as a string (24-hour notation)
*/
async function generateSupportLog(time) {
try {
const [file, stream] = Gio.File.new_tmp('gsconnect.XXXXXX');
const logFile = stream.get_output_stream();
await new Promise((resolve, reject) => {
logFile.write_bytes_async(LOG_HEADER, 0, null, (file, res) => {
try {
resolve(file.write_bytes_finish(res));
} catch (e) {
reject(e);
}
});
});
// FIXME: BSD???
const proc = new Gio.Subprocess({
flags: (Gio.SubprocessFlags.STDOUT_PIPE |
Gio.SubprocessFlags.STDERR_MERGE),
argv: ['journalctl', '--no-host', '--since', time],
});
proc.init(null);
logFile.splice_async(
proc.get_stdout_pipe(),
Gio.OutputStreamSpliceFlags.CLOSE_TARGET,
GLib.PRIORITY_DEFAULT,
null,
(source, res) => {
try {
source.splice_finish(res);
} catch (e) {
logError(e);
}
}
);
await new Promise((resolve, reject) => {
proc.wait_check_async(null, (proc, res) => {
try {
resolve(proc.wait_finish(res));
} catch (e) {
reject(e);
}
});
});
const uri = file.get_uri();
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
} catch (e) {
logError(e);
}
}
/**
* "Connect to..." Dialog
*/
const ConnectDialog = GObject.registerClass({
GTypeName: 'GSConnectConnectDialog',
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/connect-dialog.ui',
Children: [
'cancel-button', 'connect-button',
'lan-grid', 'lan-ip', 'lan-port',
],
}, class ConnectDialog extends Gtk.Dialog {
_init(params = {}) {
super._init(Object.assign({
use_header_bar: true,
}, params));
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
try {
let address;
// Lan host/port entered
if (this.lan_ip.text) {
const host = this.lan_ip.text;
const port = this.lan_port.value;
address = GLib.Variant.new_string(`lan://${host}:${port}`);
} else {
return false;
}
this.application.activate_action('connect', address);
} catch (e) {
logError(e);
}
}
this.destroy();
return false;
}
});
export const Window = GObject.registerClass({
GTypeName: 'GSConnectPreferencesWindow',
Properties: {
'display-mode': GObject.ParamSpec.string(
'display-mode',
'Display Mode',
'Display devices in either the Panel or User Menu',
GObject.ParamFlags.READWRITE,
null
),
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/preferences-window.ui',
Children: [
// HeaderBar
'headerbar', 'infobar', 'stack',
'service-menu', 'service-edit', 'refresh-button',
'device-menu', 'prev-button',
// Popover
'rename-popover', 'rename', 'rename-label', 'rename-entry', 'rename-submit',
// Focus Box
'service-window', 'service-box',
// Device List
'device-list', 'device-list-spinner', 'device-list-placeholder',
],
}, class PreferencesWindow extends Gtk.ApplicationWindow {
_init(params = {}) {
super._init(params);
// Service Settings
this.settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect',
true
),
});
// Service Proxy
this.service = new Service();
this._deviceAddedId = this.service.connect(
'device-added',
this._onDeviceAdded.bind(this)
);
this._deviceRemovedId = this.service.connect(
'device-removed',
this._onDeviceRemoved.bind(this)
);
this._serviceChangedId = this.service.connect(
'notify::active',
this._onServiceChanged.bind(this)
);
// HeaderBar (Service Name)
this.headerbar.title = this.settings.get_string('name');
this.rename_entry.text = this.headerbar.title;
// Scroll with keyboard focus
this.service_box.set_focus_vadjustment(this.service_window.vadjustment);
// Device List
this.device_list.set_header_func(rowSeparators);
// Discoverable InfoBar
this.settings.bind(
'discoverable',
this.infobar,
'reveal-child',
Gio.SettingsBindFlags.INVERT_BOOLEAN
);
this.add_action(this.settings.create_action('discoverable'));
// Application Menu
this._initMenu();
// Setting: Keep Alive When Locked
this.add_action(this.settings.create_action('keep-alive-when-locked'));
// Broadcast automatically every 5 seconds if there are no devices yet
this._refreshSource = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
5,
this._refresh.bind(this)
);
// Restore window size/maximized/position
this._restoreGeometry();
// Prime the service
this._initService();
}
get display_mode() {
if (this.settings.get_boolean('show-indicators'))
return 'panel';
return 'user-menu';
}
set display_mode(mode) {
this.settings.set_boolean('show-indicators', (mode === 'panel'));
}
vfunc_delete_event(event) {
if (this.service) {
this.service.disconnect(this._deviceAddedId);
this.service.disconnect(this._deviceRemovedId);
this.service.disconnect(this._serviceChangedId);
this.service.destroy();
this.service = null;
}
this._saveGeometry();
GLib.source_remove(this._refreshSource);
return false;
}
async _initService() {
try {
this.refresh_button.grab_focus();
this._onServiceChanged(this.service, null);
await this.service.reload();
} catch (e) {
logError(e, 'GSConnect');
}
}
_initMenu() {
// Panel/User Menu mode
const displayMode = new Gio.PropertyAction({
name: 'display-mode',
property_name: 'display-mode',
object: this,
});
this.add_action(displayMode);
// About Dialog
const aboutDialog = new Gio.SimpleAction({name: 'about'});
aboutDialog.connect('activate', this._aboutDialog.bind(this));
this.add_action(aboutDialog);
// "Connect to..." Dialog
const connectDialog = new Gio.SimpleAction({name: 'connect'});
connectDialog.connect('activate', this._connectDialog.bind(this));
this.add_action(connectDialog);
// "Generate Support Log" GAction
const generateSupportLog = new Gio.SimpleAction({name: 'support-log'});
generateSupportLog.connect('activate', this._generateSupportLog.bind(this));
this.add_action(generateSupportLog);
// "Help" GAction
const help = new Gio.SimpleAction({name: 'help'});
help.connect('activate', this._help);
this.add_action(help);
}
_refresh() {
if (this.service.active && this.device_list.get_children().length < 1) {
this.device_list_spinner.active = true;
this.service.activate_action('refresh', null);
} else {
this.device_list_spinner.active = false;
}
return GLib.SOURCE_CONTINUE;
}
/*
* Window State
*/
_restoreGeometry() {
this._windowState = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect.WindowState',
true
),
path: '/org/gnome/shell/extensions/gsconnect/preferences/',
});
// Size
const [width, height] = this._windowState.get_value('window-size').deepUnpack();
if (width && height)
this.set_default_size(width, height);
// Maximized State
if (this._windowState.get_boolean('window-maximized'))
this.maximize();
}
_saveGeometry() {
const state = this.get_window().get_state();
// Maximized State
const maximized = (state & Gdk.WindowState.MAXIMIZED);
this._windowState.set_boolean('window-maximized', maximized);
// Leave the size at the value before maximizing
if (maximized || (state & Gdk.WindowState.FULLSCREEN))
return;
// Size
const size = this.get_size();
this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
}
/**
* About Dialog
*/
_aboutDialog() {
if (this._about === undefined) {
this._about = new Gtk.AboutDialog({
application: Gio.Application.get_default(),
authors: [
'Andy Holmes <andrew.g.r.holmes@gmail.com>',
'Bertrand Lacoste <getzze@gmail.com>',
'Frank Dana <ferdnyc@gmail.com>',
],
comments: _('A complete KDE Connect implementation for GNOME'),
logo: GdkPixbuf.Pixbuf.new_from_resource_at_scale(
'/org/gnome/Shell/Extensions/GSConnect/icons/org.gnome.Shell.Extensions.GSConnect.svg',
128,
128,
true
),
program_name: 'GSConnect',
// TRANSLATORS: eg. 'Translator Name <your.email@domain.com>'
translator_credits: _('translator-credits'),
version: Config.PACKAGE_VERSION.toString(),
website: Config.PACKAGE_URL,
license_type: Gtk.License.GPL_2_0,
modal: true,
transient_for: this,
});
// Persist
this._about.connect('response', (dialog) => dialog.hide_on_delete());
this._about.connect('delete-event', (dialog) => dialog.hide_on_delete());
}
this._about.present();
}
/**
* Connect to..." Dialog
*/
_connectDialog() {
new ConnectDialog({
application: Gio.Application.get_default(),
modal: true,
transient_for: this,
});
}
/*
* "Generate Support Log" GAction
*/
_generateSupportLog() {
const dialog = new Gtk.MessageDialog({
text: _('Generate Support Log'),
secondary_text: _('Debug messages are being logged. Take any steps necessary to reproduce a problem then review the log.'),
});
dialog.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
dialog.add_button(_('Review Log'), Gtk.ResponseType.OK);
// Enable debug logging and mark the current time
this.settings.set_boolean('debug', true);
const now = GLib.DateTime.new_now_local().format('%R');
dialog.connect('response', (dialog, response_id) => {
// Disable debug logging and destroy the dialog
this.settings.set_boolean('debug', false);
dialog.destroy();
// Only generate a log if instructed
if (response_id === Gtk.ResponseType.OK)
generateSupportLog(now);
});
dialog.show_all();
}
/*
* "Help" GAction
*/
_help(action, parameter) {
const uri = `${Config.PACKAGE_URL}/wiki/Help`;
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
/*
* HeaderBar Callbacks
*/
_onPrevious(button, event) {
// HeaderBar (Service)
this.prev_button.visible = false;
this.device_menu.visible = false;
this.refresh_button.visible = true;
this.service_edit.visible = true;
this.service_menu.visible = true;
this.headerbar.title = this.settings.get_string('name');
this.headerbar.subtitle = null;
// Panel
this.stack.visible_child_name = 'service';
this._setDeviceMenu();
}
_onEditServiceName(button, event) {
this.rename_entry.text = this.headerbar.title;
this.rename_entry.has_focus = true;
}
_onSetServiceName(widget) {
if (this.rename_entry.text.length) {
this.headerbar.title = this.rename_entry.text;
this.settings.set_string('name', this.rename_entry.text);
}
this.service_edit.active = false;
this.service_edit.grab_focus();
}
/*
* Context Switcher
*/
_getTypeLabel(device) {
switch (device.type) {
case 'laptop':
return _('Laptop');
case 'phone':
return _('Smartphone');
case 'tablet':
return _('Tablet');
case 'tv':
return _('Television');
default:
return _('Desktop');
}
}
_setDeviceMenu(panel = null) {
this.device_menu.insert_action_group('device', null);
this.device_menu.insert_action_group('settings', null);
this.device_menu.set_menu_model(null);
if (panel === null)
return;
this.device_menu.insert_action_group('device', panel.device.action_group);
this.device_menu.insert_action_group('settings', panel.actions);
this.device_menu.set_menu_model(panel.menu);
}
_onDeviceChanged(statusLabel, device, pspec) {
switch (false) {
case device.paired:
statusLabel.label = _('Unpaired');
break;
case device.connected:
statusLabel.label = _('Disconnected');
break;
default:
statusLabel.label = _('Connected');
}
}
_createDeviceRow(device) {
const row = new Gtk.ListBoxRow({
height_request: 52,
selectable: false,
visible: true,
});
row.set_name(device.id);
const grid = new Gtk.Grid({
column_spacing: 12,
margin_left: 20,
margin_right: 20,
margin_bottom: 8,
margin_top: 8,
visible: true,
});
row.add(grid);
const icon = new Gtk.Image({
gicon: new Gio.ThemedIcon({name: device.icon_name}),
icon_size: Gtk.IconSize.BUTTON,
visible: true,
});
grid.attach(icon, 0, 0, 1, 1);
const title = new Gtk.Label({
halign: Gtk.Align.START,
hexpand: true,
valign: Gtk.Align.CENTER,
vexpand: true,
visible: true,
});
grid.attach(title, 1, 0, 1, 1);
const status = new Gtk.Label({
halign: Gtk.Align.END,
hexpand: true,
valign: Gtk.Align.CENTER,
vexpand: true,
visible: true,
});
grid.attach(status, 2, 0, 1, 1);
// Keep name up to date
device.bind_property(
'name',
title,
'label',
GObject.BindingFlags.SYNC_CREATE
);
// Keep status up to date
device.connect(
'notify::connected',
this._onDeviceChanged.bind(null, status)
);
device.connect(
'notify::paired',
this._onDeviceChanged.bind(null, status)
);
this._onDeviceChanged(status, device, null);
return row;
}
_onDeviceAdded(service, device) {
try {
if (!this.stack.get_child_by_name(device.id)) {
// Add the device preferences
const prefs = new Panel(device);
this.stack.add_titled(prefs, device.id, device.name);
// Add a row to the device list
prefs.row = this._createDeviceRow(device);
this.device_list.add(prefs.row);
}
} catch (e) {
logError(e);
}
}
_onDeviceRemoved(service, device) {
try {
const prefs = this.stack.get_child_by_name(device.id);
if (prefs === null)
return;
if (prefs === this.stack.get_visible_child())
this._onPrevious();
prefs.row.destroy();
prefs.row = null;
prefs.dispose();
prefs.destroy();
} catch (e) {
logError(e);
}
}
_onDeviceSelected(box, row) {
try {
if (row === null)
return this._onPrevious();
// Transition the panel
const name = row.get_name();
const prefs = this.stack.get_child_by_name(name);
this.stack.visible_child = prefs;
this._setDeviceMenu(prefs);
// HeaderBar (Device)
this.refresh_button.visible = false;
this.service_edit.visible = false;
this.service_menu.visible = false;
this.prev_button.visible = true;
this.device_menu.visible = true;
this.headerbar.title = prefs.device.name;
this.headerbar.subtitle = this._getTypeLabel(prefs.device);
} catch (e) {
logError(e);
}
}
_onServiceChanged(service, pspec) {
if (this.service.active)
this.device_list_placeholder.label = _('Searching for devices…');
else
this.device_list_placeholder.label = _('Waiting for service…');
}
});

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Adw from 'gi://Adw';
// Bootstrap
import * as Utils from './shell/utils.js';
import setup from './utils/setup.js';
import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class GSConnectExtensionPreferences extends ExtensionPreferences {
constructor(metadata) {
super(metadata);
setup(this.path);
Utils.installService();
}
fillPreferencesWindow(window) {
const widget = new Adw.PreferencesPage();
window.add(widget);
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
window.close();
});
Gio.Subprocess.new([`${this.path}/gsconnect-preferences`], 0);
}
}

View File

@@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
SPDX-License-Identifier: GPL-2.0-or-later
-->
<schemalist gettext-domain="org.gnome.Shell.Extensions.GSConnect">
<schema id="org.gnome.Shell.Extensions.GSConnect" path="/org/gnome/shell/extensions/gsconnect/">
<!-- Extension Settings -->
<key name="enabled" type="b">
<default>true</default>
</key>
<key name="show-indicators" type="b">
<default>false</default>
</key>
<key name="keep-alive-when-locked" type="b">
<default>true</default>
</key>
<key name="create-native-messaging-hosts" type="b">
<default>true</default>
</key>
<!-- Service Settings -->
<key name="id" type="s">
<default>""</default>
</key>
<key name="name" type="s">
<default>""</default>
</key>
<key name="devices" type="as">
<default>[]</default>
</key>
<key name="debug" type="b">
<default>false</default>
</key>
<key name="discoverable" type="b">
<default>true</default>
</key>
</schema>
<!-- Window Geometry -->
<schema id="org.gnome.Shell.Extensions.GSConnect.WindowState">
<key name="window-size" type="(ii)">
<default>(0, 0)</default>
</key>
<key name="window-maximized" type="b">
<default>false</default>
</key>
</schema>
<!-- Device Settings -->
<schema id="org.gnome.Shell.Extensions.GSConnect.Device">
<key name="certificate-pem" type="s">
<default>""</default>
</key>
<key name="keybindings" type="a{ss}">
<default>{}</default>
</key>
<key name="menu-actions" type="as">
<default>["sms", "ring", "mount", "commands", "share", "keyboard"]</default>
</key>
<key name="name" type="s">
<default>""</default>
</key>
<key name="paired" type="b">
<default>false</default>
</key>
<key name="type" type="s">
<default>"smartphone"</default>
</key>
<key name="incoming-capabilities" type="as">
<default>[]</default>
</key>
<key name="outgoing-capabilities" type="as">
<default>[]</default>
</key>
<key name="disabled-plugins" type="as">
<default>[]</default>
</key>
<key name="supported-plugins" type="as">
<default>[]</default>
</key>
<key name="last-connection" type="s">
<default>""</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Battery">
<key name="send-statistics" type="b">
<default>false</default>
</key>
<key name="low-battery-notification" type="b">
<default>true</default>
</key>
<key name="custom-battery-notification" type="b">
<default>false</default>
<summary>Enables custom battery notification</summary>
</key>
<key name="custom-battery-notification-value" type="u">
<range min="1" max="99"></range>
<default>80</default>
</key>
<key name="full-battery-notification" type="b">
<default>false</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Clipboard">
<key name="receive-content" type="b">
<default>false</default>
</key>
<key name="send-content" type="b">
<default>false</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts">
<key name="contacts-source" type="b">
<default>true</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.FindMyPhone"/>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Mousepad">
<key name="share-control" type="b">
<default>true</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS">
<key name="share-players" type="b">
<default>true</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Notification">
<key name="send-notifications" type="b">
<default>true</default>
</key>
<key name="send-active" type="b">
<default>true</default>
</key>
<key name="applications" type="s">
<default>'{}'</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Ping"/>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Presenter"/>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.RunCommand">
<key name="command-list" type="a{sv}">
<default><![CDATA[{'lock': <{'name': 'Lock', 'command': 'xdg-screensaver lock'}>, 'restart': <{'name': 'Restart', 'command': 'systemctl reboot'}>, 'logout': <{'name': 'Log Out', 'command': 'gnome-session-quit --logout --no-prompt'}>, 'poweroff': <{'name': 'Power Off', 'command': 'systemctl poweroff'}>, 'suspend': <{'name': 'Suspend', 'command': 'systemctl suspend'}>}]]></default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP">
<key name="automount" type="b">
<default>true</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Share">
<key name="receive-files" type="b">
<default>true</default>
</key>
<key name="receive-directory" type="s">
<default>""</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.SMS">
<key name="legacy-sms" type="b">
<default>false</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.SystemVolume">
<key name="share-sinks" type="b">
<default>true</default>
</key>
</schema>
<schema id="org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony">
<key name="ringing-volume" type="s">
<default>"lower"</default>
</key>
<key name="ringing-pause" type="b">
<default>false</default>
</key>
<key name="talking-volume" type="s">
<default>"mute"</default>
</key>
<key name="talking-microphone" type="b">
<default>true</default>
</key>
<key name="talking-pause" type="b">
<default>true</default>
</key>
</schema>
</schemalist>

View File

@@ -0,0 +1,889 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
import * as Core from '../core.js';
// Retain compatibility with GLib < 2.80, which lacks GioUnix
let GioUnix;
try {
GioUnix = (await import('gi://GioUnix')).default;
} catch (e) {
GioUnix = {
InputStream: Gio.UnixInputStream,
OutputStream: Gio.UnixOutputStream,
};
}
/**
* TCP Port Constants
*/
const PROTOCOL_PORT_DEFAULT = 1716;
const PROTOCOL_PORT_MIN = 1716;
const PROTOCOL_PORT_MAX = 1764;
const TRANSFER_MIN = 1739;
const TRANSFER_MAX = 1764;
/*
* One-time check for Linux/FreeBSD socket options
*/
export let _LINUX_SOCKETS = true;
try {
// This should throw on FreeBSD
Gio.Socket.new(
Gio.SocketFamily.IPV4,
Gio.SocketType.STREAM,
Gio.SocketProtocol.TCP
).get_option(6, 5);
} catch (e) {
_LINUX_SOCKETS = false;
}
/**
* Configure a socket connection for the KDE Connect protocol.
*
* @param {Gio.SocketConnection} connection - The connection to configure
*/
export function _configureSocket(connection) {
try {
if (_LINUX_SOCKETS) {
connection.socket.set_option(6, 4, 10); // TCP_KEEPIDLE
connection.socket.set_option(6, 5, 5); // TCP_KEEPINTVL
connection.socket.set_option(6, 6, 3); // TCP_KEEPCNT
// FreeBSD constants
// https://github.com/freebsd/freebsd/blob/master/sys/netinet/tcp.h#L159
} else {
connection.socket.set_option(6, 256, 10); // TCP_KEEPIDLE
connection.socket.set_option(6, 512, 5); // TCP_KEEPINTVL
connection.socket.set_option(6, 1024, 3); // TCP_KEEPCNT
}
// Do this last because an error setting the keepalive options would
// result in a socket that never times out
connection.socket.set_keepalive(true);
} catch (e) {
debug(e, 'Configuring Socket');
}
}
/**
* Lan.ChannelService consists of two parts:
*
* The TCP Listener listens on a port and constructs a Channel object from the
* incoming Gio.TcpConnection.
*
* The UDP Listener listens on a port for incoming JSON identity packets which
* include the TCP port, while the IP address is taken from the UDP packet
* itself. We respond by opening a TCP connection to that address.
*/
export const ChannelService = GObject.registerClass({
GTypeName: 'GSConnectLanChannelService',
Properties: {
'certificate': GObject.ParamSpec.object(
'certificate',
'Certificate',
'The TLS certificate',
GObject.ParamFlags.READWRITE,
Gio.TlsCertificate.$gtype
),
'port': GObject.ParamSpec.uint(
'port',
'Port',
'The port used by the service',
GObject.ParamFlags.READWRITE,
0, GLib.MAXUINT16,
PROTOCOL_PORT_DEFAULT
),
},
}, class LanChannelService extends Core.ChannelService {
_init(params = {}) {
super._init(params);
// Track hosts we identify to directly, allowing them to ignore the
// discoverable state of the service.
this._allowed = new Set();
//
this._tcp = null;
this._tcpPort = PROTOCOL_PORT_DEFAULT;
this._udp4 = null;
this._udp6 = null;
// Monitor network status
this._networkMonitor = Gio.NetworkMonitor.get_default();
this._networkAvailable = false;
this._networkChangedId = 0;
}
get certificate() {
if (this._certificate === undefined)
this._certificate = null;
return this._certificate;
}
set certificate(certificate) {
if (this.certificate === certificate)
return;
this._certificate = certificate;
this.notify('certificate');
}
get channels() {
if (this._channels === undefined)
this._channels = new Map();
return this._channels;
}
get port() {
if (this._port === undefined)
this._port = PROTOCOL_PORT_DEFAULT;
return this._port;
}
set port(port) {
if (this.port === port)
return;
this._port = port;
this.notify('port');
}
_onNetworkChanged(monitor, network_available) {
if (this._networkAvailable === network_available)
return;
this._networkAvailable = network_available;
this.broadcast();
}
_initCertificate() {
if (GLib.find_program_in_path(Config.OPENSSL_PATH) === null) {
const error = new Error();
error.name = _('OpenSSL not found');
error.url = `${Config.PACKAGE_URL}/wiki/Error#openssl-not-found`;
throw error;
}
const certPath = GLib.build_filenamev([
Config.CONFIGDIR,
'certificate.pem',
]);
const keyPath = GLib.build_filenamev([
Config.CONFIGDIR,
'private.pem',
]);
// Ensure a certificate exists with our id as the common name
this._certificate = Gio.TlsCertificate.new_for_paths(certPath, keyPath,
this.id);
// If the service ID doesn't match the common name, this is probably a
// certificate from an older version and we should amend ours to match
if (this.id !== this._certificate.common_name)
this._id = this._certificate.common_name;
}
_initTcpListener() {
try {
this._tcp = new Gio.SocketService();
let tcpPort = this.port;
const tcpPortMax = tcpPort +
(PROTOCOL_PORT_MAX - PROTOCOL_PORT_MIN);
while (tcpPort <= tcpPortMax) {
try {
this._tcp.add_inet_port(tcpPort, null);
break;
} catch (e) {
if (tcpPort < tcpPortMax) {
tcpPort++;
continue;
}
throw e;
}
}
this._tcpPort = tcpPort;
this._tcp.connect('incoming', this._onIncomingChannel.bind(this));
} catch (e) {
this._tcp.stop();
this._tcp.close();
this._tcp = null;
throw e;
}
}
async _onIncomingChannel(listener, connection) {
try {
const host = connection.get_remote_address().address.to_string();
// Create a channel
const channel = new Channel({
backend: this,
certificate: this.certificate,
host: host,
port: this.port,
});
// Accept the connection
await channel.accept(connection);
channel.identity.body.tcpHost = channel.host;
channel.identity.body.tcpPort = this._tcpPort;
channel.allowed = this._allowed.has(host);
this.channel(channel);
} catch (e) {
debug(e);
}
}
_initUdpListener() {
// Default broadcast address
this._udp_address = Gio.InetSocketAddress.new_from_string(
'255.255.255.255', this.port);
try {
this._udp6 = Gio.Socket.new(Gio.SocketFamily.IPV6,
Gio.SocketType.DATAGRAM, Gio.SocketProtocol.UDP);
this._udp6.set_broadcast(true);
// Bind the socket
const inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV6);
const sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
this._udp6.bind(sockAddr, true);
// Input stream
this._udp6_stream = new Gio.DataInputStream({
base_stream: new GioUnix.InputStream({
fd: this._udp6.fd,
close_fd: false,
}),
});
// Watch socket for incoming packets
this._udp6_source = this._udp6.create_source(GLib.IOCondition.IN, null);
this._udp6_source.set_callback(this._onIncomingIdentity.bind(this, this._udp6));
this._udp6_source.attach(null);
} catch (e) {
this._udp6 = null;
}
// Our IPv6 socket also supports IPv4; we're all done
if (this._udp6 && this._udp6.speaks_ipv4()) {
this._udp4 = null;
return;
}
try {
this._udp4 = Gio.Socket.new(Gio.SocketFamily.IPV4,
Gio.SocketType.DATAGRAM, Gio.SocketProtocol.UDP);
this._udp4.set_broadcast(true);
// Bind the socket
const inetAddr = Gio.InetAddress.new_any(Gio.SocketFamily.IPV4);
const sockAddr = Gio.InetSocketAddress.new(inetAddr, this.port);
this._udp4.bind(sockAddr, true);
// Input stream
this._udp4_stream = new Gio.DataInputStream({
base_stream: new GioUnix.InputStream({
fd: this._udp4.fd,
close_fd: false,
}),
});
// Watch input socket for incoming packets
this._udp4_source = this._udp4.create_source(GLib.IOCondition.IN, null);
this._udp4_source.set_callback(this._onIncomingIdentity.bind(this, this._udp4));
this._udp4_source.attach(null);
} catch (e) {
this._udp4 = null;
// We failed to get either an IPv4 or IPv6 socket to bind
if (this._udp6 === null)
throw e;
}
}
_onIncomingIdentity(socket) {
let host;
// Try to peek the remote address
try {
host = socket.receive_message([], Gio.SocketMsgFlags.PEEK, null)[1]
.address.to_string();
} catch (e) {
logError(e);
}
// Whether or not we peeked the address, we need to read the packet
try {
let data;
if (socket === this._udp6)
data = this._udp6_stream.read_line_utf8(null)[0];
else
data = this._udp4_stream.read_line_utf8(null)[0];
// Discard the packet if we failed to peek the address
if (host === undefined)
return GLib.SOURCE_CONTINUE;
const packet = new Core.Packet(data);
packet.body.tcpHost = host;
this._onIdentity(packet);
} catch (e) {
logError(e);
}
return GLib.SOURCE_CONTINUE;
}
async _onIdentity(packet) {
try {
// Bail if the deviceId is missing
if (!packet.body.hasOwnProperty('deviceId'))
return;
// Silently ignore our own broadcasts
if (packet.body.deviceId === this.identity.body.deviceId)
return;
debug(packet);
// Create a new channel
const channel = new Channel({
backend: this,
certificate: this.certificate,
host: packet.body.tcpHost,
port: packet.body.tcpPort,
identity: packet,
});
// Check if channel is already open with this address
if (this.channels.has(channel.address))
return;
this._channels.set(channel.address, channel);
// Open a TCP connection
const address = Gio.InetSocketAddress.new_from_string(
packet.body.tcpHost, packet.body.tcpPort);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address,
this.cancellable);
// Connect the channel and attach it to the device on success
await channel.open(connection);
this.channel(channel);
} catch (e) {
logError(e);
}
}
/**
* Broadcast an identity packet
*
* If @address is not %null it may specify an IPv4 or IPv6 address to send
* the identity packet directly to, otherwise it will be broadcast to the
* default address, 255.255.255.255.
*
* @param {string} [address] - An optional target IPv4 or IPv6 address
*/
broadcast(address = null) {
try {
if (!this._networkAvailable)
return;
// Try to parse strings as <host>:<port>
if (typeof address === 'string') {
const [host, portstr] = address.split(':');
const port = parseInt(portstr) || this.port;
address = Gio.InetSocketAddress.new_from_string(host, port);
}
// If we succeed, remember this host
if (address instanceof Gio.InetSocketAddress) {
this._allowed.add(address.address.to_string());
// Broadcast to the network if no address is specified
} else {
debug('Broadcasting to LAN');
address = this._udp_address;
}
// Broadcast on each open socket
if (this._udp6 !== null)
this._udp6.send_to(address, this.identity.serialize(), null);
if (this._udp4 !== null)
this._udp4.send_to(address, this.identity.serialize(), null);
} catch (e) {
debug(e, address);
}
}
buildIdentity() {
// Chain-up, then add the TCP port
super.buildIdentity();
this.identity.body.tcpPort = this._tcpPort;
}
start() {
if (this.active)
return;
// Ensure a certificate exists
if (this.certificate === null)
this._initCertificate();
// Start TCP/UDP listeners
try {
if (this._tcp === null)
this._initTcpListener();
if (this._udp4 === null && this._udp6 === null)
this._initUdpListener();
} catch (e) {
// Known case of another application using the protocol defined port
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ADDRESS_IN_USE)) {
e.name = _('Port already in use');
e.url = `${Config.PACKAGE_URL}/wiki/Error#port-already-in-use`;
}
throw e;
}
// Monitor network changes
if (this._networkChangedId === 0) {
this._networkAvailable = this._networkMonitor.network_available;
this._networkChangedId = this._networkMonitor.connect(
'network-changed', this._onNetworkChanged.bind(this));
}
this._active = true;
this.notify('active');
}
stop() {
if (this._networkChangedId) {
this._networkMonitor.disconnect(this._networkChangedId);
this._networkChangedId = 0;
this._networkAvailable = false;
}
if (this._tcp !== null) {
this._tcp.stop();
this._tcp.close();
this._tcp = null;
}
if (this._udp6 !== null) {
this._udp6_source.destroy();
this._udp6_stream.close(null);
this._udp6.close();
this._udp6 = null;
}
if (this._udp4 !== null) {
this._udp4_source.destroy();
this._udp4_stream.close(null);
this._udp4.close();
this._udp4 = null;
}
for (const channel of this.channels.values())
channel.close();
this._active = false;
this.notify('active');
}
destroy() {
try {
this.stop();
} catch (e) {
debug(e);
}
}
});
/**
* Lan Channel
*
* This class essentially just extends Core.Channel to set TCP socket options
* and negotiate TLS encrypted connections.
*/
export const Channel = GObject.registerClass({
GTypeName: 'GSConnectLanChannel',
}, class LanChannel extends Core.Channel {
_init(params) {
super._init();
Object.assign(this, params);
}
get address() {
return `lan://${this.host}:${this.port}`;
}
get certificate() {
if (this._certificate === undefined)
this._certificate = null;
return this._certificate;
}
set certificate(certificate) {
this._certificate = certificate;
}
get peer_certificate() {
if (this._connection instanceof Gio.TlsConnection)
return this._connection.get_peer_certificate();
return null;
}
get host() {
if (this._host === undefined)
this._host = null;
return this._host;
}
set host(host) {
this._host = host;
}
get port() {
if (this._port === undefined) {
if (this.identity && this.identity.body.tcpPort)
this._port = this.identity.body.tcpPort;
else
return PROTOCOL_PORT_DEFAULT;
}
return this._port;
}
set port(port) {
this._port = port;
}
/**
* Authenticate a TLS connection.
*
* @param {Gio.TlsConnection} connection - A TLS connection
* @return {Promise} A promise for the operation
*/
async _authenticate(connection) {
// Standard TLS Handshake
connection.validation_flags = Gio.TlsCertificateFlags.EXPIRED;
connection.authentication_mode = Gio.TlsAuthenticationMode.REQUIRED;
await connection.handshake_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
// Get a settings object for the device
let settings;
if (this.device) {
settings = this.device.settings;
} else {
const id = this.identity.body.deviceId;
settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect.Device',
true
),
path: `/org/gnome/shell/extensions/gsconnect/device/${id}/`,
});
}
// If we have a certificate for this deviceId, we can verify it
const cert_pem = settings.get_string('certificate-pem');
if (cert_pem !== '') {
let certificate = null;
let verified = false;
try {
certificate = Gio.TlsCertificate.new_from_pem(cert_pem, -1);
verified = certificate.is_same(connection.peer_certificate);
} catch (e) {
logError(e);
}
/* The certificate is incorrect for one of two reasons, but both
* result in us resetting the certificate and unpairing the device.
*
* If the certificate failed to load, it is probably corrupted or
* otherwise invalid. In this case, if we try to continue we will
* certainly crash the Android app.
*
* If the certificate did not match what we expected the obvious
* thing to do is to notify the user, however experience tells us
* this is a result of the user doing something masochistic like
* nuking the Android app data or copying settings between machines.
*/
if (verified === false) {
if (this.device) {
this.device.unpair();
} else {
settings.reset('paired');
settings.reset('certificate-pem');
}
const name = this.identity.body.deviceName;
throw new Error(`${name}: Authentication Failure`);
}
}
return connection;
}
/**
* Wrap the connection in Gio.TlsClientConnection and initiate handshake
*
* @param {Gio.TcpConnection} connection - The unauthenticated connection
* @return {Gio.TlsClientConnection} The authenticated connection
*/
_encryptClient(connection) {
_configureSocket(connection);
connection = Gio.TlsClientConnection.new(connection,
connection.socket.remote_address);
connection.set_certificate(this.certificate);
return this._authenticate(connection);
}
/**
* Wrap the connection in Gio.TlsServerConnection and initiate handshake
*
* @param {Gio.TcpConnection} connection - The unauthenticated connection
* @return {Gio.TlsServerConnection} The authenticated connection
*/
_encryptServer(connection) {
_configureSocket(connection);
connection = Gio.TlsServerConnection.new(connection, this.certificate);
// We're the server so we trust-on-first-use and verify after
const _id = connection.connect('accept-certificate', (connection) => {
connection.disconnect(_id);
return true;
});
return this._authenticate(connection);
}
/**
* Negotiate an incoming connection
*
* @param {Gio.TcpConnection} connection - The incoming connection
*/
async accept(connection) {
debug(`${this.address} (${this.uuid})`);
try {
this._connection = connection;
this.backend.channels.set(this.address, this);
// In principle this disposable wrapper could buffer more than the
// identity packet, but in practice the remote device shouldn't send
// any more data until the TLS connection is negotiated.
const stream = new Gio.DataInputStream({
base_stream: connection.input_stream,
close_base_stream: false,
});
const data = await stream.read_line_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
this.identity = new Core.Packet(data[0]);
if (!this.identity.body.deviceId)
throw new Error('missing deviceId');
this._connection = await this._encryptClient(connection);
} catch (e) {
this.close();
throw e;
}
}
/**
* Negotiate an outgoing connection
*
* @param {Gio.SocketConnection} connection - The remote connection
*/
async open(connection) {
debug(`${this.address} (${this.uuid})`);
try {
this._connection = connection;
this.backend.channels.set(this.address, this);
await connection.get_output_stream().write_all_async(
this.backend.identity.serialize(),
GLib.PRIORITY_DEFAULT,
this.cancellable);
this._connection = await this._encryptServer(connection);
} catch (e) {
this.close();
throw e;
}
}
/**
* Close all streams associated with this channel, silencing any errors
*/
close() {
if (this.closed)
return;
debug(`${this.address} (${this.uuid})`);
this._closed = true;
this.notify('closed');
this.backend.channels.delete(this.address);
this.cancellable.cancel();
if (this._connection)
this._connection.close_async(GLib.PRIORITY_DEFAULT, null, null);
if (this.input_stream)
this.input_stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
if (this.output_stream)
this.output_stream.close_async(GLib.PRIORITY_DEFAULT, null, null);
}
async download(packet, target, cancellable = null) {
const address = Gio.InetSocketAddress.new_from_string(this.host,
packet.payloadTransferInfo.port);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address, cancellable)
.then(this._encryptClient.bind(this));
// Start the transfer
const transferredSize = await target.splice_async(
connection.input_stream,
(Gio.OutputStreamSpliceFlags.CLOSE_SOURCE |
Gio.OutputStreamSpliceFlags.CLOSE_TARGET),
GLib.PRIORITY_DEFAULT, cancellable);
// If we get less than expected, we've certainly got corruption
if (transferredSize < packet.payloadSize) {
throw new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.FAILED,
message: `Incomplete: ${transferredSize}/${packet.payloadSize}`,
});
// TODO: sometimes kdeconnect-android under-reports a file's size
// https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/1157
} else if (transferredSize > packet.payloadSize) {
logError(new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.FAILED,
message: `Extra Data: ${transferredSize - packet.payloadSize}`,
}));
}
}
async upload(packet, source, size, cancellable = null) {
// Start listening on the first available port between 1739-1764
const listener = new Gio.SocketListener();
let port = TRANSFER_MIN;
while (port <= TRANSFER_MAX) {
try {
listener.add_inet_port(port, null);
break;
} catch (e) {
if (port < TRANSFER_MAX) {
port++;
continue;
} else {
throw e;
}
}
}
// Listen for the incoming connection
const acceptConnection = listener.accept_async(cancellable)
.then(result => this._encryptServer(result[0]));
// Create an upload request
packet.body.payloadHash = this.checksum;
packet.payloadSize = size;
packet.payloadTransferInfo = {port: port};
const requestUpload = this.sendPacket(new Core.Packet(packet),
cancellable);
// Request an upload stream, accept the connection and get the output
const [, connection] = await Promise.all([requestUpload,
acceptConnection]);
// Start the transfer
const transferredSize = await connection.output_stream.splice_async(
source,
(Gio.OutputStreamSpliceFlags.CLOSE_SOURCE |
Gio.OutputStreamSpliceFlags.CLOSE_TARGET),
GLib.PRIORITY_DEFAULT, cancellable);
if (transferredSize !== size) {
throw new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.PARTIAL_INPUT,
message: 'Transfer incomplete',
});
}
}
async rejectTransfer(packet) {
try {
if (!packet || !packet.hasPayload())
return;
if (packet.payloadTransferInfo.port === undefined)
return;
const address = Gio.InetSocketAddress.new_from_string(this.host,
packet.payloadTransferInfo.port);
const client = new Gio.SocketClient({enable_proxy: false});
const connection = await client.connect_async(address, null)
.then(this._encryptClient.bind(this));
connection.close_async(GLib.PRIORITY_DEFAULT, null, null);
} catch (e) {
debug(e, this.device.name);
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,694 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import plugins from './plugins/index.js';
/**
* Get the local device type.
*
* @return {string} A device type string
*/
export function _getDeviceType() {
try {
let type = GLib.file_get_contents('/sys/class/dmi/id/chassis_type')[1];
type = Number(new TextDecoder().decode(type));
if ([8, 9, 10, 14].includes(type))
return 'laptop';
return 'desktop';
} catch (e) {
return 'desktop';
}
}
/**
* The packet class is a simple Object-derived class, offering some conveniences
* for working with KDE Connect packets.
*/
export class Packet {
constructor(data = null) {
this.id = 0;
this.type = undefined;
this.body = {};
if (typeof data === 'string')
Object.assign(this, JSON.parse(data));
else if (data !== null)
Object.assign(this, data);
}
[Symbol.toPrimitive](hint) {
this.id = Date.now();
if (hint === 'string')
return `${JSON.stringify(this)}\n`;
if (hint === 'number')
return `${JSON.stringify(this)}\n`.length;
return true;
}
get [Symbol.toStringTag]() {
return `Packet:${this.type}`;
}
/**
* Deserialize and return a new Packet from an Object or string.
*
* @param {Object|string} data - A string or dictionary to deserialize
* @return {Core.Packet} A new packet object
*/
static deserialize(data) {
return new Packet(data);
}
/**
* Serialize the packet as a single line with a terminating new-line (`\n`)
* character, ready to be written to a channel.
*
* @return {string} A serialized packet
*/
serialize() {
this.id = Date.now();
return `${JSON.stringify(this)}\n`;
}
/**
* Update the packet from a dictionary or string of JSON
*
* @param {Object|string} data - Source data
*/
update(data) {
try {
if (typeof data === 'string')
Object.assign(this, JSON.parse(data));
else
Object.assign(this, data);
} catch (e) {
throw Error(`Malformed data: ${e.message}`);
}
}
/**
* Check if the packet has a payload.
*
* @return {boolean} %true if @packet has a payload
*/
hasPayload() {
if (!this.hasOwnProperty('payloadSize'))
return false;
if (!this.hasOwnProperty('payloadTransferInfo'))
return false;
return (Object.keys(this.payloadTransferInfo).length > 0);
}
}
/**
* Channel objects handle KDE Connect packet exchange and data transfers for
* devices. The implementation is responsible for all negotiation of the
* underlying protocol.
*/
export const Channel = GObject.registerClass({
GTypeName: 'GSConnectChannel',
Properties: {
'closed': GObject.ParamSpec.boolean(
'closed',
'Closed',
'Whether the channel has been closed',
GObject.ParamFlags.READABLE,
false
),
},
}, class Channel extends GObject.Object {
get address() {
throw new GObject.NotImplementedError();
}
get backend() {
if (this._backend === undefined)
this._backend = null;
return this._backend;
}
set backend(backend) {
this._backend = backend;
}
get cancellable() {
if (this._cancellable === undefined)
this._cancellable = new Gio.Cancellable();
return this._cancellable;
}
get closed() {
if (this._closed === undefined)
this._closed = false;
return this._closed;
}
get input_stream() {
if (this._input_stream === undefined) {
if (this._connection instanceof Gio.IOStream)
return this._connection.get_input_stream();
return null;
}
return this._input_stream;
}
set input_stream(stream) {
this._input_stream = stream;
}
get output_stream() {
if (this._output_stream === undefined) {
if (this._connection instanceof Gio.IOStream)
return this._connection.get_output_stream();
return null;
}
return this._output_stream;
}
set output_stream(stream) {
this._output_stream = stream;
}
get uuid() {
if (this._uuid === undefined)
this._uuid = GLib.uuid_string_random();
return this._uuid;
}
set uuid(uuid) {
this._uuid = uuid;
}
/**
* Close the channel.
*/
close() {
throw new GObject.NotImplementedError();
}
/**
* Read a packet.
*
* @param {Gio.Cancellable} [cancellable] - A cancellable
* @return {Promise<Core.Packet>} The packet
*/
async readPacket(cancellable = null) {
if (cancellable === null)
cancellable = this.cancellable;
if (!(this.input_stream instanceof Gio.DataInputStream)) {
this.input_stream = new Gio.DataInputStream({
base_stream: this.input_stream,
});
}
const [data] = await this.input_stream.read_line_async(
GLib.PRIORITY_DEFAULT, cancellable);
if (data === null) {
throw new Gio.IOErrorEnum({
message: 'End of stream',
code: Gio.IOErrorEnum.CONNECTION_CLOSED,
});
}
return new Packet(data);
}
/**
* Send a packet.
*
* @param {Core.Packet} packet - The packet to send
* @param {Gio.Cancellable} [cancellable] - A cancellable
* @return {Promise<boolean>} %true if successful
*/
sendPacket(packet, cancellable = null) {
if (cancellable === null)
cancellable = this.cancellable;
return this.output_stream.write_all_async(packet.serialize(),
GLib.PRIORITY_DEFAULT, cancellable);
}
/**
* Reject a transfer.
*
* @param {Core.Packet} packet - A packet with payload info
*/
rejectTransfer(packet) {
throw new GObject.NotImplementedError();
}
/**
* Download a payload from a device. Typically implementations will override
* this with an async function.
*
* @param {Core.Packet} packet - A packet
* @param {Gio.OutputStream} target - The target stream
* @param {Gio.Cancellable} [cancellable] - A cancellable for the upload
*/
download(packet, target, cancellable = null) {
throw new GObject.NotImplementedError();
}
/**
* Upload a payload to a device. Typically implementations will override
* this with an async function.
*
* @param {Core.Packet} packet - The packet describing the transfer
* @param {Gio.InputStream} source - The source stream
* @param {number} size - The payload size
* @param {Gio.Cancellable} [cancellable] - A cancellable for the upload
*/
upload(packet, source, size, cancellable = null) {
throw new GObject.NotImplementedError();
}
});
/**
* ChannelService implementations provide Channel objects, emitting the
* ChannelService::channel signal when a new connection has been accepted.
*/
export const ChannelService = GObject.registerClass({
GTypeName: 'GSConnectChannelService',
Properties: {
'active': GObject.ParamSpec.boolean(
'active',
'Active',
'Whether the service is active',
GObject.ParamFlags.READABLE,
false
),
'id': GObject.ParamSpec.string(
'id',
'ID',
'The hostname or other network unique id',
GObject.ParamFlags.READWRITE,
null
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'The name of the backend',
GObject.ParamFlags.READWRITE,
null
),
},
Signals: {
'channel': {
flags: GObject.SignalFlags.RUN_LAST,
param_types: [Channel.$gtype],
return_type: GObject.TYPE_BOOLEAN,
},
},
}, class ChannelService extends GObject.Object {
get active() {
if (this._active === undefined)
this._active = false;
return this._active;
}
get cancellable() {
if (this._cancellable === undefined)
this._cancellable = new Gio.Cancellable();
return this._cancellable;
}
get name() {
if (this._name === undefined)
this._name = GLib.get_host_name();
return this._name;
}
set name(name) {
if (this.name === name)
return;
this._name = name;
this.notify('name');
}
get id() {
if (this._id === undefined)
this._id = GLib.uuid_string_random();
return this._id;
}
set id(id) {
if (this.id === id)
return;
this._id = id;
}
get identity() {
if (this._identity === undefined)
this.buildIdentity();
return this._identity;
}
/**
* Broadcast directly to @address or the whole network if %null
*
* @param {string} [address] - A string address
*/
broadcast(address = null) {
throw new GObject.NotImplementedError();
}
/**
* Rebuild the identity packet used to identify the local device. An
* implementation may override this to make modifications to the default
* capabilities if necessary (eg. bluez without SFTP support).
*/
buildIdentity() {
this._identity = new Packet({
id: 0,
type: 'kdeconnect.identity',
body: {
deviceId: this.id,
deviceName: this.name,
deviceType: _getDeviceType(),
protocolVersion: 7,
incomingCapabilities: [],
outgoingCapabilities: [],
},
});
for (const name in plugins) {
const meta = plugins[name].Metadata;
if (meta === undefined)
continue;
for (const type of meta.incomingCapabilities)
this._identity.body.incomingCapabilities.push(type);
for (const type of meta.outgoingCapabilities)
this._identity.body.outgoingCapabilities.push(type);
}
}
/**
* Emit Core.ChannelService::channel
*
* @param {Core.Channel} channel - The new channel
*/
channel(channel) {
if (!this.emit('channel', channel))
channel.close();
}
/**
* Start the channel service. Implementations should throw an error if the
* service fails to meet any of its requirements for opening or accepting
* connections.
*/
start() {
throw new GObject.NotImplementedError();
}
/**
* Stop the channel service.
*/
stop() {
throw new GObject.NotImplementedError();
}
/**
* Destroy the channel service.
*/
destroy() {
}
});
/**
* A class representing a file transfer.
*/
export const Transfer = GObject.registerClass({
GTypeName: 'GSConnectTransfer',
Properties: {
'channel': GObject.ParamSpec.object(
'channel',
'Channel',
'The channel that owns this transfer',
GObject.ParamFlags.READWRITE,
Channel.$gtype
),
'completed': GObject.ParamSpec.boolean(
'completed',
'Completed',
'Whether the transfer has completed',
GObject.ParamFlags.READABLE,
false
),
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device that created this transfer',
GObject.ParamFlags.READWRITE,
GObject.Object.$gtype
),
},
}, class Transfer extends GObject.Object {
_init(params = {}) {
super._init(params);
this._cancellable = new Gio.Cancellable();
this._items = [];
}
get channel() {
if (this._channel === undefined)
this._channel = null;
return this._channel;
}
set channel(channel) {
if (this.channel === channel)
return;
this._channel = channel;
}
get completed() {
if (this._completed === undefined)
this._completed = false;
return this._completed;
}
get device() {
if (this._device === undefined)
this._device = null;
return this._device;
}
set device(device) {
if (this.device === device)
return;
this._device = device;
}
get uuid() {
if (this._uuid === undefined)
this._uuid = GLib.uuid_string_random();
return this._uuid;
}
/**
* Ensure there is a stream for the transfer item.
*
* @param {Object} item - A transfer item
* @param {Gio.Cancellable} [cancellable] - A cancellable
*/
async _ensureStream(item, cancellable = null) {
// This is an upload from a remote device
if (item.packet.hasPayload()) {
if (item.target instanceof Gio.OutputStream)
return;
if (item.file instanceof Gio.File) {
item.target = await item.file.replace_async(
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
GLib.PRIORITY_DEFAULT,
this._cancellable);
}
} else {
if (item.source instanceof Gio.InputStream)
return;
if (item.file instanceof Gio.File) {
const read = item.file.read_async(GLib.PRIORITY_DEFAULT,
cancellable);
const query = item.file.query_info_async(
Gio.FILE_ATTRIBUTE_STANDARD_SIZE,
Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT,
cancellable);
const [stream, info] = await Promise.all([read, query]);
item.source = stream;
item.size = info.get_size();
}
}
}
/**
* Add a file to the transfer.
*
* @param {Core.Packet} packet - A packet
* @param {Gio.File} file - A file to transfer
*/
addFile(packet, file) {
const item = {
packet: new Packet(packet),
file: file,
source: null,
target: null,
};
this._items.push(item);
}
/**
* Add a filepath to the transfer.
*
* @param {Core.Packet} packet - A packet
* @param {string} path - A filepath to transfer
*/
addPath(packet, path) {
const item = {
packet: new Packet(packet),
file: Gio.File.new_for_path(path),
source: null,
target: null,
};
this._items.push(item);
}
/**
* Add a stream to the transfer.
*
* @param {Core.Packet} packet - A packet
* @param {Gio.InputStream|Gio.OutputStream} stream - A stream to transfer
* @param {number} [size] - Payload size
*/
addStream(packet, stream, size = 0) {
const item = {
packet: new Packet(packet),
file: null,
source: null,
target: null,
size: size,
};
if (stream instanceof Gio.InputStream)
item.source = stream;
else if (stream instanceof Gio.OutputStream)
item.target = stream;
this._items.push(item);
}
/**
* Execute a transfer operation. Implementations may override this, while
* the default uses g_output_stream_splice().
*
* @param {Gio.Cancellable} [cancellable] - A cancellable
*/
async start(cancellable = null) {
let error = null;
try {
let item;
// If a cancellable is passed in, chain to its signal
if (cancellable instanceof Gio.Cancellable)
cancellable.connect(() => this._cancellable.cancel());
while ((item = this._items.shift())) {
// If created for a device, ignore connection changes by
// ensuring we have the most recent channel
if (this.device !== null)
this._channel = this.device.channel;
// TODO: transfer queueing?
if (this.channel === null || this.channel.closed) {
throw new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.CONNECTION_CLOSED,
message: 'Channel is closed',
});
}
await this._ensureStream(item, this._cancellable);
if (item.packet.hasPayload()) {
await this.channel.download(item.packet, item.target,
this._cancellable);
} else {
await this.channel.upload(item.packet, item.source,
item.size, this._cancellable);
}
}
} catch (e) {
error = e;
} finally {
this._completed = true;
this.notify('completed');
}
if (error !== null)
throw error;
}
cancel() {
if (this._cancellable.is_cancelled() === false)
this._cancellable.cancel();
}
});

View File

@@ -0,0 +1,702 @@
#!/usr/bin/env -S gjs -m
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk?version=3.0';
import 'gi://GdkPixbuf?version=2.0';
import Gio from 'gi://Gio?version=2.0';
import 'gi://GIRepository?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import GObject from 'gi://GObject?version=2.0';
import Gtk from 'gi://Gtk?version=3.0';
import 'gi://Pango?version=1.0';
import system from 'system';
import './init.js';
import Config from '../config.js';
import Manager from './manager.js';
import * as ServiceUI from './ui/service.js';
import('gi://GioUnix?version=2.0').catch(() => {}); // Set version for optional dependency
/**
* Class representing the GSConnect service daemon.
*/
const Service = GObject.registerClass({
GTypeName: 'GSConnectService',
}, class Service extends Gtk.Application {
_init() {
super._init({
application_id: 'org.gnome.Shell.Extensions.GSConnect',
flags: Gio.ApplicationFlags.HANDLES_OPEN,
resource_base_path: '/org/gnome/Shell/Extensions/GSConnect',
});
GLib.set_prgname('gsconnect');
GLib.set_application_name('GSConnect');
// Command-line
this._initOptions();
}
get settings() {
if (this._settings === undefined) {
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
}
return this._settings;
}
/*
* GActions
*/
_initActions() {
const actions = [
['connect', this._identify.bind(this), new GLib.VariantType('s')],
['device', this._device.bind(this), new GLib.VariantType('(ssbv)')],
['error', this._error.bind(this), new GLib.VariantType('a{ss}')],
['preferences', this._preferences, null],
['quit', () => this.quit(), null],
['refresh', this._identify.bind(this), null],
];
for (const [name, callback, type] of actions) {
const action = new Gio.SimpleAction({
name: name,
parameter_type: type,
});
action.connect('activate', callback);
this.add_action(action);
}
}
/**
* A wrapper for Device GActions. This is used to route device notification
* actions to their device, since GNotifications need an 'app' level action.
*
* @param {Gio.Action} action - The GAction
* @param {GLib.Variant} parameter - The activation parameter
*/
_device(action, parameter) {
try {
parameter = parameter.unpack();
// Select the appropriate device(s)
let devices;
const id = parameter[0].unpack();
if (id === '*')
devices = this.manager.devices.values();
else
devices = [this.manager.devices.get(id)];
// Unpack the action data and activate the action
const name = parameter[1].unpack();
const target = parameter[2].unpack() ? parameter[3].unpack() : null;
for (const device of devices)
device.activate_action(name, target);
} catch (e) {
logError(e);
}
}
_error(action, parameter) {
try {
const error = parameter.deepUnpack();
// If there's a URL, we have better information in the Wiki
if (error.url !== undefined) {
Gio.AppInfo.launch_default_for_uri_async(
error.url,
null,
null,
null
);
return;
}
const dialog = new ServiceUI.ErrorDialog(error);
dialog.present();
} catch (e) {
logError(e);
}
}
_identify(action, parameter) {
try {
let uri = null;
if (parameter instanceof GLib.Variant)
uri = parameter.unpack();
this.manager.identify(uri);
} catch (e) {
logError(e);
}
}
_preferences() {
Gio.Subprocess.new(
[`${Config.PACKAGE_DATADIR}/gsconnect-preferences`],
Gio.SubprocessFlags.NONE
);
}
/**
* Report a service-level error
*
* @param {Object} error - An Error or object with name, message and stack
*/
notify_error(error) {
try {
// Always log the error
logError(error);
// Create an new notification
let id, body, priority;
const notif = new Gio.Notification();
const icon = new Gio.ThemedIcon({name: 'dialog-error'});
let target = null;
if (error.name === undefined)
error.name = 'Error';
if (error.url !== undefined) {
id = error.url;
body = _('Click for help troubleshooting');
priority = Gio.NotificationPriority.URGENT;
target = new GLib.Variant('a{ss}', {
name: error.name.trim(),
message: error.message.trim(),
stack: error.stack.trim(),
url: error.url,
});
} else {
id = error.message.trim();
body = _('Click for more information');
priority = Gio.NotificationPriority.HIGH;
target = new GLib.Variant('a{ss}', {
name: error.name.trim(),
message: error.message.trim(),
stack: error.stack.trim(),
});
}
notif.set_title(`GSConnect: ${error.name.trim()}`);
notif.set_body(body);
notif.set_icon(icon);
notif.set_priority(priority);
notif.set_default_action_and_target('app.error', target);
this.send_notification(id, notif);
} catch (e) {
logError(e);
}
}
vfunc_activate() {
super.vfunc_activate();
}
vfunc_startup() {
super.vfunc_startup();
this.hold();
// Watch *this* file and stop the service if it's updated/uninstalled
this._serviceMonitor = Gio.File.new_for_path(
`${Config.PACKAGE_DATADIR}/service/daemon.js`
).monitor(Gio.FileMonitorFlags.WATCH_MOVES, null);
this._serviceMonitor.connect('changed', () => this.quit());
// Init some resources
const provider = new Gtk.CssProvider();
provider.load_from_resource(`${Config.APP_PATH}/application.css`);
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
);
// Ensure our handlers are registered
try {
const appInfo = Gio.DesktopAppInfo.new(`${Config.APP_ID}.desktop`);
appInfo.add_supports_type('x-scheme-handler/sms');
appInfo.add_supports_type('x-scheme-handler/tel');
} catch (e) {
debug(e);
}
// GActions & GSettings
this._initActions();
this.manager.start();
}
vfunc_dbus_register(connection, object_path) {
if (!super.vfunc_dbus_register(connection, object_path))
return false;
this.manager = new Manager({
connection: connection,
object_path: object_path,
});
return true;
}
vfunc_dbus_unregister(connection, object_path) {
this.manager.destroy();
super.vfunc_dbus_unregister(connection, object_path);
}
vfunc_open(files, hint) {
super.vfunc_open(files, hint);
for (const file of files) {
let action, parameter, title;
try {
switch (file.get_uri_scheme()) {
case 'sms':
title = _('Send SMS');
action = 'uriSms';
parameter = new GLib.Variant('s', file.get_uri());
break;
case 'tel':
title = _('Dial Number');
action = 'shareUri';
parameter = new GLib.Variant('s', file.get_uri());
break;
case 'file':
title = _('Share File');
action = 'shareFile';
parameter = new GLib.Variant('(sb)', [file.get_uri(), false]);
break;
default:
throw new Error(`Unsupported URI: ${file.get_uri()}`);
}
// Show chooser dialog
new ServiceUI.DeviceChooser({
title: title,
action_name: action,
action_target: parameter,
});
} catch (e) {
logError(e, `GSConnect: Opening ${file.get_uri()}`);
}
}
}
vfunc_shutdown() {
// Dispose GSettings
if (this._settings !== undefined)
this.settings.run_dispose();
this.manager.stop();
// Exhaust the event loop to ensure any pending operations complete
const context = GLib.MainContext.default();
while (context.iteration(false))
continue;
// Force a GC to prevent any more calls back into JS, then chain-up
system.gc();
super.vfunc_shutdown();
}
/*
* CLI
*/
_initOptions() {
/*
* Device Listings
*/
this.add_main_option(
'list-devices',
'l'.charCodeAt(0),
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('List available devices'),
null
);
this.add_main_option(
'list-all',
'a'.charCodeAt(0),
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('List all devices'),
null
);
this.add_main_option(
'device',
'd'.charCodeAt(0),
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Target Device'),
'<device-id>'
);
/**
* Pairing
*/
this.add_main_option(
'pair',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('Pair'),
null
);
this.add_main_option(
'unpair',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('Unpair'),
null
);
/*
* Messaging
*/
this.add_main_option(
'message',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING_ARRAY,
_('Send SMS'),
'<phone-number>'
);
this.add_main_option(
'message-body',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Message Body'),
'<text>'
);
/*
* Notifications
*/
this.add_main_option(
'notification',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Send Notification'),
'<title>'
);
this.add_main_option(
'notification-appname',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Notification App Name'),
'<name>'
);
this.add_main_option(
'notification-body',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Notification Body'),
'<text>'
);
this.add_main_option(
'notification-icon',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Notification Icon'),
'<icon-name>'
);
this.add_main_option(
'notification-id',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Notification ID'),
'<id>'
);
this.add_main_option(
'ping',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('Ping'),
null
);
this.add_main_option(
'ring',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('Ring'),
null
);
/*
* Sharing
*/
this.add_main_option(
'share-file',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.FILENAME_ARRAY,
_('Share File'),
'<filepath|URI>'
);
this.add_main_option(
'share-link',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING_ARRAY,
_('Share Link'),
'<URL>'
);
this.add_main_option(
'share-text',
0,
GLib.OptionFlags.NONE,
GLib.OptionArg.STRING,
_('Share Text'),
'<text>'
);
/*
* Misc
*/
this.add_main_option(
'version',
'v'.charCodeAt(0),
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
_('Show release version'),
null
);
}
_cliAction(id, name, parameter = null) {
const parameters = [];
if (parameter instanceof GLib.Variant)
parameters[0] = parameter;
id = id.replace(/\W+/g, '_');
Gio.DBus.session.call_sync(
'org.gnome.Shell.Extensions.GSConnect',
`/org/gnome/Shell/Extensions/GSConnect/Device/${id}`,
'org.gtk.Actions',
'Activate',
GLib.Variant.new('(sava{sv})', [name, parameters, {}]),
null,
Gio.DBusCallFlags.NONE,
-1,
null
);
}
_cliListDevices(full = true) {
const result = Gio.DBus.session.call_sync(
'org.gnome.Shell.Extensions.GSConnect',
'/org/gnome/Shell/Extensions/GSConnect',
'org.freedesktop.DBus.ObjectManager',
'GetManagedObjects',
null,
null,
Gio.DBusCallFlags.NONE,
-1,
null
);
const variant = result.unpack()[0].unpack();
let device;
for (let object of Object.values(variant)) {
object = object.recursiveUnpack();
device = object['org.gnome.Shell.Extensions.GSConnect.Device'];
if (full)
print(`${device.Id}\t${device.Name}\t${device.Connected}\t${device.Paired}`);
else if (device.Connected && device.Paired)
print(device.Id);
}
}
_cliMessage(id, options) {
if (!options.contains('message-body'))
throw new TypeError('missing --message-body option');
// TODO: currently we only support single-recipient messaging
const addresses = options.lookup_value('message', null).deepUnpack();
const body = options.lookup_value('message-body', null).deepUnpack();
this._cliAction(
id,
'sendSms',
GLib.Variant.new('(ss)', [addresses[0], body])
);
}
_cliNotify(id, options) {
const title = options.lookup_value('notification', null).unpack();
let body = '';
let icon = null;
let nid = `${Date.now()}`;
let appName = 'GSConnect CLI';
if (options.contains('notification-id'))
nid = options.lookup_value('notification-id', null).unpack();
if (options.contains('notification-body'))
body = options.lookup_value('notification-body', null).unpack();
if (options.contains('notification-appname'))
appName = options.lookup_value('notification-appname', null).unpack();
if (options.contains('notification-icon')) {
icon = options.lookup_value('notification-icon', null).unpack();
icon = Gio.Icon.new_for_string(icon);
} else {
icon = new Gio.ThemedIcon({
name: 'org.gnome.Shell.Extensions.GSConnect',
});
}
const notification = new GLib.Variant('a{sv}', {
appName: GLib.Variant.new_string(appName),
id: GLib.Variant.new_string(nid),
title: GLib.Variant.new_string(title),
text: GLib.Variant.new_string(body),
ticker: GLib.Variant.new_string(`${title}: ${body}`),
time: GLib.Variant.new_string(`${Date.now()}`),
isClearable: GLib.Variant.new_boolean(true),
icon: icon.serialize(),
});
this._cliAction(id, 'sendNotification', notification);
}
_cliShareFile(device, options) {
const files = options.lookup_value('share-file', null).deepUnpack();
for (let file of files) {
file = new TextDecoder().decode(file);
this._cliAction(device, 'shareFile', GLib.Variant.new('(sb)', [file, false]));
}
}
_cliShareLink(device, options) {
const uris = options.lookup_value('share-link', null).unpack();
for (const uri of uris)
this._cliAction(device, 'shareUri', uri);
}
_cliShareText(device, options) {
const text = options.lookup_value('share-text', null).unpack();
this._cliAction(device, 'shareText', GLib.Variant.new_string(text));
}
vfunc_handle_local_options(options) {
try {
if (options.contains('version')) {
print(`GSConnect ${Config.PACKAGE_VERSION}`);
return 0;
}
this.register(null);
if (options.contains('list-devices')) {
this._cliListDevices(false);
return 0;
}
if (options.contains('list-all')) {
this._cliListDevices(true);
return 0;
}
// We need a device for anything else; exit since this is probably
// the daemon being started.
if (!options.contains('device'))
return -1;
const id = options.lookup_value('device', null).unpack();
// Pairing
if (options.contains('pair')) {
this._cliAction(id, 'pair');
return 0;
}
if (options.contains('unpair')) {
this._cliAction(id, 'unpair');
return 0;
}
// Plugins
if (options.contains('message'))
this._cliMessage(id, options);
if (options.contains('notification'))
this._cliNotify(id, options);
if (options.contains('ping'))
this._cliAction(id, 'ping', GLib.Variant.new_string(''));
if (options.contains('ring'))
this._cliAction(id, 'ring');
if (options.contains('share-file'))
this._cliShareFile(id, options);
if (options.contains('share-link'))
this._cliShareLink(id, options);
if (options.contains('share-text'))
this._cliShareText(id, options);
return 0;
} catch (e) {
logError(e);
return 1;
}
}
});
await (new Service()).runAsync([system.programInvocationName].concat(ARGV));

View File

@@ -0,0 +1,428 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import {watchService} from '../wl_clipboard.js';
import Gio from 'gi://Gio';
import GIRepository from 'gi://GIRepository';
import GLib from 'gi://GLib';
import Config from '../config.js';
import setup, {setupGettext} from '../utils/setup.js';
// Promise Wrappers
// We don't use top-level await since it returns control flow to importing module, causing bugs
import('gi://EBook').then(({default: EBook}) => {
Gio._promisify(EBook.BookClient, 'connect');
Gio._promisify(EBook.BookClient.prototype, 'get_view');
Gio._promisify(EBook.BookClient.prototype, 'get_contacts');
}).catch(console.debug);
import('gi://EDataServer').then(({default: EDataServer}) => {
Gio._promisify(EDataServer.SourceRegistry, 'new');
}).catch(console.debug);
Gio._promisify(Gio.AsyncInitable.prototype, 'init_async');
Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Gio.DBusProxy.prototype, 'call');
Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async',
'read_line_finish_utf8');
Gio._promisify(Gio.File.prototype, 'delete_async');
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
Gio._promisify(Gio.File.prototype, 'mount_enclosing_volume');
Gio._promisify(Gio.File.prototype, 'query_info_async');
Gio._promisify(Gio.File.prototype, 'read_async');
Gio._promisify(Gio.File.prototype, 'replace_async');
Gio._promisify(Gio.File.prototype, 'replace_contents_bytes_async',
'replace_contents_finish');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.Mount.prototype, 'unmount_with_operation');
Gio._promisify(Gio.InputStream.prototype, 'close_async');
Gio._promisify(Gio.OutputStream.prototype, 'close_async');
Gio._promisify(Gio.OutputStream.prototype, 'splice_async');
Gio._promisify(Gio.OutputStream.prototype, 'write_all_async');
Gio._promisify(Gio.SocketClient.prototype, 'connect_async');
Gio._promisify(Gio.SocketListener.prototype, 'accept_async');
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
Gio._promisify(Gio.TlsConnection.prototype, 'handshake_async');
Gio._promisify(Gio.DtlsConnection.prototype, 'handshake_async');
// User Directories
Config.CACHEDIR = GLib.build_filenamev([GLib.get_user_cache_dir(), 'gsconnect']);
Config.CONFIGDIR = GLib.build_filenamev([GLib.get_user_config_dir(), 'gsconnect']);
Config.RUNTIMEDIR = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gsconnect']);
// Bootstrap
const serviceFolder = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]);
const extensionFolder = GLib.path_get_dirname(serviceFolder);
setup(extensionFolder);
setupGettext();
if (Config.IS_USER) {
// Infer libdir by assuming gnome-shell shares a common prefix with gjs;
// assume the parent directory if it's not there
let libdir = GIRepository.Repository.get_search_path().find(path => {
return path.endsWith('/gjs/girepository-1.0');
}).replace('/gjs/girepository-1.0', '');
const gsdir = GLib.build_filenamev([libdir, 'gnome-shell']);
if (!GLib.file_test(gsdir, GLib.FileTest.IS_DIR)) {
const currentDir = `/${GLib.path_get_basename(libdir)}`;
libdir = libdir.replace(currentDir, '');
}
Config.GNOME_SHELL_LIBDIR = libdir;
}
// Load DBus interfaces
Config.DBUS = (() => {
const bytes = Gio.resources_lookup_data(
GLib.build_filenamev([Config.APP_PATH, `${Config.APP_ID}.xml`]),
Gio.ResourceLookupFlags.NONE
);
const xml = new TextDecoder().decode(bytes.toArray());
const dbus = Gio.DBusNodeInfo.new_for_xml(xml);
dbus.nodes.forEach(info => info.cache_build());
return dbus;
})();
// Init User Directories
for (const path of [Config.CACHEDIR, Config.CONFIGDIR, Config.RUNTIMEDIR])
GLib.mkdir_with_parents(path, 0o755);
globalThis.HAVE_GNOME = GLib.getenv('GSCONNECT_MODE')?.toLowerCase() !== 'cli' && (GLib.getenv('GNOME_SETUP_DISPLAY') !== null || GLib.getenv('XDG_CURRENT_DESKTOP')?.toUpperCase()?.includes('GNOME') || GLib.getenv('XDG_SESSION_DESKTOP')?.toLowerCase() === 'gnome');
/**
* A custom debug function that logs at LEVEL_MESSAGE to avoid the need for env
* variables to be set.
*
* @param {Error|string} message - A string or Error to log
* @param {string} [prefix] - An optional prefix for the warning
*/
const _debugCallerMatch = new RegExp(/([^@]*)@([^:]*):([^:]*)/);
// eslint-disable-next-line func-style
const _debugFunc = function (error, prefix = null) {
let caller, message;
if (error.stack) {
caller = error.stack.split('\n')[0];
message = `${error.message}\n${error.stack}`;
} else {
caller = (new Error()).stack.split('\n')[1];
message = JSON.stringify(error, null, 2);
}
if (prefix)
message = `${prefix}: ${message}`;
const [, func, file, line] = _debugCallerMatch.exec(caller);
const script = file.replace(Config.PACKAGE_DATADIR, '');
GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, {
'MESSAGE': `[${script}:${func}:${line}]: ${message}`,
'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect',
'CODE_FILE': file,
'CODE_FUNC': func,
'CODE_LINE': line,
});
};
// Swap the function out for a no-op anonymous function for speed
const settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
settings.connect('changed::debug', (settings, key) => {
globalThis.debug = settings.get_boolean(key) ? _debugFunc : () => {};
});
if (settings.get_boolean('debug'))
globalThis.debug = _debugFunc;
else
globalThis.debug = () => {};
/**
* Start wl_clipboard if not under Gnome
*/
if (!globalThis.HAVE_GNOME) {
debug('Not running as a Gnome extension');
watchService();
}
/**
* A simple (for now) pre-comparison sanitizer for phone numbers
* See: https://github.com/KDE/kdeconnect-kde/blob/master/smsapp/conversationlistmodel.cpp#L200-L210
*
* @return {string} Return the string stripped of leading 0, and ' ()-+'
*/
String.prototype.toPhoneNumber = function () {
const strippedNumber = this.replace(/^0*|[ ()+-]/g, '');
if (strippedNumber.length)
return strippedNumber;
return this;
};
/**
* A simple equality check for phone numbers based on `toPhoneNumber()`
*
* @param {string} number - A phone number string to compare
* @return {boolean} If `this` and @number are equivalent phone numbers
*/
String.prototype.equalsPhoneNumber = function (number) {
const a = this.toPhoneNumber();
const b = number.toPhoneNumber();
return (a.length && b.length && (a.endsWith(b) || b.endsWith(a)));
};
/**
* An implementation of `rm -rf` in Gio
*
* @param {Gio.File|string} file - a GFile or filepath
*/
Gio.File.rm_rf = function (file) {
try {
if (typeof file === 'string')
file = Gio.File.new_for_path(file);
try {
const iter = file.enumerate_children(
'standard::name',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null
);
let info;
while ((info = iter.next_file(null)))
Gio.File.rm_rf(iter.get_child(info));
iter.close(null);
} catch (e) {
// Silence errors
}
file.delete(null);
} catch (e) {
// Silence errors
}
};
/**
* Extend GLib.Variant with a static method to recursively pack a variant
*
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
* @return {GLib.Variant} The resulting GVariant
*/
function _full_pack(obj) {
let packed;
const type = typeof obj;
switch (true) {
case (obj instanceof GLib.Variant):
return obj;
case (type === 'string'):
return GLib.Variant.new('s', obj);
case (type === 'number'):
return GLib.Variant.new('d', obj);
case (type === 'boolean'):
return GLib.Variant.new('b', obj);
case (obj instanceof Uint8Array):
return GLib.Variant.new('ay', obj);
case (obj === null):
return GLib.Variant.new('mv', null);
case (typeof obj.map === 'function'):
return GLib.Variant.new(
'av',
obj.filter(e => e !== undefined).map(e => _full_pack(e))
);
case (obj instanceof Gio.Icon):
return obj.serialize();
case (type === 'object'):
packed = {};
for (const [key, val] of Object.entries(obj)) {
if (val !== undefined)
packed[key] = _full_pack(val);
}
return GLib.Variant.new('a{sv}', packed);
default:
throw Error(`Unsupported type '${type}': ${obj}`);
}
}
GLib.Variant.full_pack = _full_pack;
/**
* Extend GLib.Variant with a method to recursively deepUnpack() a variant
*
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
* @return {*} The resulting object
*/
function _full_unpack(obj) {
obj = (obj === undefined) ? this : obj;
const unpacked = {};
switch (true) {
case (obj === null):
return obj;
case (obj instanceof GLib.Variant):
return _full_unpack(obj.deepUnpack());
case (obj instanceof Uint8Array):
return obj;
case (typeof obj.map === 'function'):
return obj.map(e => _full_unpack(e));
case (typeof obj === 'object'):
for (const [key, value] of Object.entries(obj)) {
// Try to detect and deserialize GIcons
try {
if (key === 'icon' && value.get_type_string() === '(sv)')
unpacked[key] = Gio.Icon.deserialize(value);
else
unpacked[key] = _full_unpack(value);
} catch (e) {
unpacked[key] = _full_unpack(value);
}
}
return unpacked;
default:
return obj;
}
}
GLib.Variant.prototype.full_unpack = _full_unpack;
/**
* Creates a GTlsCertificate from the PEM-encoded data in @cert_path and
* @key_path. If either are missing a new pair will be generated.
*
* Additionally, the private key will be added using ssh-add to allow sftp
* connections using Gio.
*
* See: https://github.com/KDE/kdeconnect-kde/blob/master/core/kdeconnectconfig.cpp#L119
*
* @param {string} certPath - Absolute path to a x509 certificate in PEM format
* @param {string} keyPath - Absolute path to a private key in PEM format
* @param {string} commonName - A unique common name for the certificate
* @return {Gio.TlsCertificate} A TLS certificate
*/
Gio.TlsCertificate.new_for_paths = function (certPath, keyPath, commonName = null) {
// Check if the certificate/key pair already exists
const certExists = GLib.file_test(certPath, GLib.FileTest.EXISTS);
const keyExists = GLib.file_test(keyPath, GLib.FileTest.EXISTS);
// Create a new certificate and private key if necessary
if (!certExists || !keyExists) {
// If we weren't passed a common name, generate a random one
if (!commonName)
commonName = GLib.uuid_string_random();
const proc = new Gio.Subprocess({
argv: [
Config.OPENSSL_PATH, 'req',
'-new', '-x509', '-sha256',
'-out', certPath,
'-newkey', 'rsa:4096', '-nodes',
'-keyout', keyPath,
'-days', '3650',
'-subj', `/O=andyholmes.github.io/OU=GSConnect/CN=${commonName}`,
],
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
Gio.SubprocessFlags.STDERR_SILENCE),
});
proc.init(null);
proc.wait_check(null);
}
return Gio.TlsCertificate.new_from_files(certPath, keyPath);
};
Object.defineProperties(Gio.TlsCertificate.prototype, {
/**
* The common name of the certificate.
*/
'common_name': {
get: function () {
if (!this.__common_name) {
const proc = new Gio.Subprocess({
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-subject', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
});
proc.init(null);
const stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
this.__common_name = /(?:cn|CN) ?= ?([^,\n]*)/.exec(stdout)[1];
}
return this.__common_name;
},
configurable: true,
enumerable: true,
},
/**
* Get just the pubkey as a DER ByteArray of a certificate.
*
* @return {GLib.Bytes} The pubkey as DER of the certificate.
*/
'pubkey_der': {
value: function () {
if (!this.__pubkey_der) {
let proc = new Gio.Subprocess({
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-pubkey', '-inform', 'pem'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
});
proc.init(null);
const pubkey = proc.communicate_utf8(this.certificate_pem, null)[1];
proc = new Gio.Subprocess({
argv: [Config.OPENSSL_PATH, 'pkey', '-pubin', '-inform', 'pem', '-outform', 'der'],
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
});
proc.init(null);
this.__pubkey_der = proc.communicate(new TextEncoder().encode(pubkey), null)[1];
}
return this.__pubkey_der;
},
configurable: true,
enumerable: false,
},
});

View File

@@ -0,0 +1,515 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import * as DBus from './utils/dbus.js';
import Device from './device.js';
import * as LanBackend from './backends/lan.js';
const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
const DEVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect/Device';
const DEVICE_IFACE = Config.DBUS.lookup_interface(DEVICE_NAME);
const backends = {
lan: LanBackend,
};
/**
* A manager for devices.
*/
const Manager = GObject.registerClass({
GTypeName: 'GSConnectManager',
Properties: {
'active': GObject.ParamSpec.boolean(
'active',
'Active',
'Whether the manager is active',
GObject.ParamFlags.READABLE,
false
),
'discoverable': GObject.ParamSpec.boolean(
'discoverable',
'Discoverable',
'Whether the service responds to discovery requests',
GObject.ParamFlags.READWRITE,
false
),
'id': GObject.ParamSpec.string(
'id',
'Id',
'The hostname or other network unique id',
GObject.ParamFlags.READWRITE,
null
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'The name announced to the network',
GObject.ParamFlags.READWRITE,
'GSConnect'
),
},
}, class Manager extends Gio.DBusObjectManagerServer {
_init(params = {}) {
super._init(params);
this._exported = new WeakMap();
this._reconnectId = 0;
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
this._initSettings();
}
get active() {
if (this._active === undefined)
this._active = false;
return this._active;
}
get backends() {
if (this._backends === undefined)
this._backends = new Map();
return this._backends;
}
get devices() {
if (this._devices === undefined)
this._devices = new Map();
return this._devices;
}
get discoverable() {
if (this._discoverable === undefined)
this._discoverable = this.settings.get_boolean('discoverable');
return this._discoverable;
}
set discoverable(value) {
if (this.discoverable === value)
return;
this._discoverable = value;
this.notify('discoverable');
// FIXME: This whole thing just keeps getting uglier
const application = Gio.Application.get_default();
if (application === null)
return;
if (this.discoverable) {
Gio.Application.prototype.withdraw_notification.call(
application,
'discovery-warning'
);
} else {
const notif = new Gio.Notification();
notif.set_title(_('Discovery Disabled'));
notif.set_body(_('Discovery has been disabled due to the number of devices on this network.'));
notif.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
notif.set_priority(Gio.NotificationPriority.HIGH);
notif.set_default_action('app.preferences');
Gio.Application.prototype.withdraw_notification.call(
application,
'discovery-warning',
notif
);
}
}
get id() {
if (this._id === undefined)
this._id = this.settings.get_string('id');
return this._id;
}
set id(value) {
if (this.id === value)
return;
this._id = value;
this.notify('id');
}
get name() {
if (this._name === undefined)
this._name = this.settings.get_string('name');
return this._name;
}
set name(value) {
if (this.name === value)
return;
this._name = value;
this.notify('name');
// Broadcast changes to the network
for (const backend of this.backends.values()) {
backend.name = this.name;
backend.buildIdentity();
}
this.identify();
}
get settings() {
if (this._settings === undefined) {
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
});
}
return this._settings;
}
vfunc_notify(pspec) {
if (pspec.name !== 'connection')
return;
if (this.connection !== null)
this._exportDevices();
else
this._unexportDevices();
}
/*
* GSettings
*/
_initSettings() {
// Initialize the ID and name of the service
if (this.settings.get_string('id').length === 0)
this.settings.set_string('id', GLib.uuid_string_random());
if (this.settings.get_string('name').length === 0)
this.settings.set_string('name', GLib.get_host_name());
// Bound Properties
this.settings.bind('discoverable', this, 'discoverable', 0);
this.settings.bind('id', this, 'id', 0);
this.settings.bind('name', this, 'name', 0);
}
/*
* Backends
*/
_onChannel(backend, channel) {
try {
let device = this.devices.get(channel.identity.body.deviceId);
switch (true) {
// Proceed if this is an existing device...
case (device !== undefined):
break;
// Or the connection is allowed...
case this.discoverable || channel.allowed:
device = this._ensureDevice(channel.identity);
break;
// ...otherwise bail
default:
debug(`${channel.identity.body.deviceName}: not allowed`);
return false;
}
device.setChannel(channel);
return true;
} catch (e) {
logError(e, backend.name);
return false;
}
}
_loadBackends() {
for (const name in backends) {
try {
const module = backends[name];
if (module.ChannelService === undefined)
continue;
// Try to create the backend and track it if successful
const backend = new module.ChannelService({
id: this.id,
name: this.name,
});
this.backends.set(name, backend);
// Connect to the backend
backend.__channelId = backend.connect(
'channel',
this._onChannel.bind(this)
);
// Now try to start the backend, allowing us to retry if we fail
backend.start();
} catch (e) {
if (Gio.Application.get_default())
Gio.Application.get_default().notify_error(e);
}
}
}
/*
* Devices
*/
_loadDevices() {
// Load cached devices
for (const id of this.settings.get_strv('devices')) {
const device = new Device({body: {deviceId: id}});
this._exportDevice(device);
this.devices.set(id, device);
}
}
_exportDevice(device) {
if (this.connection === null)
return;
const info = {
object: null,
interface: null,
actions: 0,
menu: 0,
};
const objectPath = `${DEVICE_PATH}/${device.id.replace(/\W+/g, '_')}`;
// Export an object path for the device
info.object = new Gio.DBusObjectSkeleton({
g_object_path: objectPath,
});
this.export(info.object);
// Export GActions & GMenu
info.actions = Gio.DBus.session.export_action_group(objectPath, device);
info.menu = Gio.DBus.session.export_menu_model(objectPath, device.menu);
// Export the Device interface
info.interface = new DBus.Interface({
g_instance: device,
g_interface_info: DEVICE_IFACE,
});
info.object.add_interface(info.interface);
this._exported.set(device, info);
}
_exportDevices() {
if (this.connection === null)
return;
for (const device of this.devices.values())
this._exportDevice(device);
}
_unexportDevice(device) {
const info = this._exported.get(device);
if (info === undefined)
return;
// Unexport GActions and GMenu
Gio.DBus.session.unexport_action_group(info.actions);
Gio.DBus.session.unexport_menu_model(info.menu);
// Unexport the Device interface and object
info.interface.flush();
info.object.remove_interface(info.interface);
info.object.flush();
this.unexport(info.object.g_object_path);
this._exported.delete(device);
}
_unexportDevices() {
for (const device of this.devices.values())
this._unexportDevice(device);
}
/**
* Return a device for @packet, creating it and adding it to the list of
* of known devices if it doesn't exist.
*
* @param {Core.Packet} packet - An identity packet for the device
* @return {Device} A device object
*/
_ensureDevice(packet) {
let device = this.devices.get(packet.body.deviceId);
if (device === undefined) {
debug(`Adding ${packet.body.deviceName}`);
// TODO: Remove when all clients support bluetooth-like discovery
//
// If this is the third unpaired device to connect, we disable
// discovery to avoid choking on networks with many devices
const unpaired = Array.from(this.devices.values()).filter(dev => {
return !dev.paired;
});
if (unpaired.length === 3)
this.discoverable = false;
device = new Device(packet);
this._exportDevice(device);
this.devices.set(device.id, device);
// Notify
this.settings.set_strv('devices', Array.from(this.devices.keys()));
}
return device;
}
/**
* Permanently remove a device.
*
* Removes the device from the list of known devices, deletes all GSettings
* and files.
*
* @param {string} id - The id of the device to delete
*/
_removeDevice(id) {
// Delete all GSettings
const settings_path = `/org/gnome/shell/extensions/gsconnect/${id}/`;
GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`);
// Delete the cache
const cache = GLib.build_filenamev([Config.CACHEDIR, id]);
Gio.File.rm_rf(cache);
// Forget the device
this.devices.delete(id);
this.settings.set_strv('devices', Array.from(this.devices.keys()));
}
/**
* A GSourceFunc that tries to reconnect to each paired device, while
* pruning unpaired devices that have disconnected.
*
* @return {boolean} Always %true
*/
_reconnect() {
for (const [id, device] of this.devices) {
if (device.connected)
continue;
if (device.paired) {
this.identify(device.settings.get_string('last-connection'));
continue;
}
this._unexportDevice(device);
this._removeDevice(id);
device.destroy();
}
return GLib.SOURCE_CONTINUE;
}
/**
* Identify to an address or broadcast to the network.
*
* @param {string} [uri] - An address URI or %null to broadcast
*/
identify(uri = null) {
try {
// If we're passed a parameter, try and find a backend for it
if (uri !== null) {
const [scheme, address] = uri.split('://');
const backend = this.backends.get(scheme);
if (backend !== undefined)
backend.broadcast(address);
// If we're not discoverable, only try to reconnect known devices
} else if (!this.discoverable) {
this._reconnect();
// Otherwise have each backend broadcast to it's network
} else {
this.backends.forEach(backend => backend.broadcast());
}
} catch (e) {
logError(e);
}
}
/**
* Start managing devices.
*/
start() {
if (this.active)
return;
this._loadDevices();
this._loadBackends();
if (this._reconnectId === 0) {
this._reconnectId = GLib.timeout_add_seconds(
GLib.PRIORITY_LOW,
5,
this._reconnect.bind(this)
);
}
this._active = true;
this.notify('active');
}
/**
* Stop managing devices.
*/
stop() {
if (!this.active)
return;
if (this._reconnectId > 0) {
GLib.Source.remove(this._reconnectId);
this._reconnectId = 0;
}
this._unexportDevices();
this.backends.forEach(backend => backend.destroy());
this.backends.clear();
this.devices.forEach(device => device.destroy());
this.devices.clear();
this._active = false;
this.notify('active');
}
/**
* Stop managing devices and free any resources.
*/
destroy() {
this.stop();
this.set_connection(null);
}
});
export default Manager;

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env -S gjs -m
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio?version=2.0';
import GLib from 'gi://GLib?version=2.0';
import GObject from 'gi://GObject?version=2.0';
import system from 'system';
// Retain compatibility with GLib < 2.80, which lacks GioUnix
let GioUnix;
try {
GioUnix = (await import('gi://GioUnix?version=2.0')).default;
} catch (e) {
GioUnix = {
InputStream: Gio.UnixInputStream,
OutputStream: Gio.UnixOutputStream,
};
}
const NativeMessagingHost = GObject.registerClass({
GTypeName: 'GSConnectNativeMessagingHost',
}, class NativeMessagingHost extends Gio.Application {
_init() {
super._init({
application_id: 'org.gnome.Shell.Extensions.GSConnect.NativeMessagingHost',
flags: Gio.ApplicationFlags.NON_UNIQUE,
});
}
get devices() {
if (this._devices === undefined)
this._devices = {};
return this._devices;
}
vfunc_activate() {
super.vfunc_activate();
}
vfunc_startup() {
super.vfunc_startup();
this.hold();
// IO Channels
this._stdin = new Gio.DataInputStream({
base_stream: new GioUnix.InputStream({fd: 0}),
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN,
});
this._stdout = new Gio.DataOutputStream({
base_stream: new GioUnix.OutputStream({fd: 1}),
byte_order: Gio.DataStreamByteOrder.HOST_ENDIAN,
});
const source = this._stdin.base_stream.create_source(null);
source.set_callback(this.receive.bind(this));
source.attach(null);
// Device Manager
try {
this._manager = Gio.DBusObjectManagerClient.new_for_bus_sync(
Gio.BusType.SESSION,
Gio.DBusObjectManagerClientFlags.DO_NOT_AUTO_START,
'org.gnome.Shell.Extensions.GSConnect',
'/org/gnome/Shell/Extensions/GSConnect',
null,
null
);
} catch (e) {
logError(e);
this.quit();
}
// Add currently managed devices
for (const object of this._manager.get_objects()) {
for (const iface of object.get_interfaces())
this._onInterfaceAdded(this._manager, object, iface);
}
// Watch for new and removed devices
this._manager.connect(
'interface-added',
this._onInterfaceAdded.bind(this)
);
this._manager.connect(
'object-removed',
this._onObjectRemoved.bind(this)
);
// Watch for device property changes
this._manager.connect(
'interface-proxy-properties-changed',
this.sendDeviceList.bind(this)
);
// Watch for service restarts
this._manager.connect(
'notify::name-owner',
this.sendDeviceList.bind(this)
);
this.send({
type: 'connected',
data: (this._manager.name_owner !== null),
});
}
receive() {
try {
// Read the message
const length = this._stdin.read_int32(null);
const bytes = this._stdin.read_bytes(length, null).toArray();
const message = JSON.parse(new TextDecoder().decode(bytes));
// A request for a list of devices
if (message.type === 'devices') {
this.sendDeviceList();
// A request to invoke an action
} else if (message.type === 'share') {
let actionName;
const device = this.devices[message.data.device];
if (device) {
if (message.data.action === 'share')
actionName = 'shareUri';
else if (message.data.action === 'telephony')
actionName = 'shareSms';
device.actions.activate_action(
actionName,
new GLib.Variant('s', message.data.url)
);
}
}
return GLib.SOURCE_CONTINUE;
} catch (e) {
this.quit();
}
}
send(message) {
try {
const data = JSON.stringify(message);
this._stdout.put_int32(data.length, null);
this._stdout.put_string(data, null);
} catch (e) {
this.quit();
}
}
sendDeviceList() {
// Inform the WebExtension we're disconnected from the service
if (this._manager && this._manager.name_owner === null)
return this.send({type: 'connected', data: false});
// Collect all the devices with supported actions
const available = [];
for (const device of Object.values(this.devices)) {
const share = device.actions.get_action_enabled('shareUri');
const telephony = device.actions.get_action_enabled('shareSms');
if (share || telephony) {
available.push({
id: device.g_object_path,
name: device.name,
type: device.type,
share: share,
telephony: telephony,
});
}
}
this.send({type: 'devices', data: available});
}
_proxyGetter(name) {
try {
return this.get_cached_property(name).unpack();
} catch (e) {
return null;
}
}
_onInterfaceAdded(manager, object, iface) {
Object.defineProperties(iface, {
'name': {
get: this._proxyGetter.bind(iface, 'Name'),
enumerable: true,
},
// TODO: phase this out for icon-name
'type': {
get: this._proxyGetter.bind(iface, 'Type'),
enumerable: true,
},
});
iface.actions = Gio.DBusActionGroup.get(
iface.g_connection,
iface.g_name,
iface.g_object_path
);
this.devices[iface.g_object_path] = iface;
this.sendDeviceList();
}
_onObjectRemoved(manager, object) {
delete this.devices[object.g_object_path];
this.sendDeviceList();
}
});
// NOTE: must not pass ARGV
await (new NativeMessagingHost()).runAsync([system.programInvocationName]);

View File

@@ -0,0 +1,251 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import plugins from './plugins/index.js';
/**
* Base class for device plugins.
*/
const Plugin = GObject.registerClass({
GTypeName: 'GSConnectPlugin',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device that owns this plugin',
GObject.ParamFlags.READABLE,
GObject.Object
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'The device name',
GObject.ParamFlags.READABLE,
null
),
},
}, class Plugin extends GObject.Object {
_init(device, name, meta = null) {
super._init();
this._device = device;
this._name = name;
this._meta = meta;
if (this._meta === null)
this._meta = plugins[name].Metadata;
// GSettings
const schema = Config.GSCHEMA.lookup(this._meta.id, false);
if (schema !== null) {
this.settings = new Gio.Settings({
settings_schema: schema,
path: `${device.settings.path}plugin/${name}/`,
});
}
// GActions
this._gactions = [];
if (this._meta.actions) {
const menu = this.device.settings.get_strv('menu-actions');
for (const name in this._meta.actions) {
const info = this._meta.actions[name];
this._registerAction(name, menu.indexOf(name), info);
}
}
}
get cancellable() {
if (this._cancellable === undefined)
this._cancellable = new Gio.Cancellable();
return this._cancellable;
}
get device() {
return this._device;
}
get name() {
return this._name;
}
_activateAction(action, parameter) {
try {
let args = null;
if (parameter instanceof GLib.Variant)
args = parameter.full_unpack();
if (Array.isArray(args))
this[action.name](...args);
else
this[action.name](args);
} catch (e) {
logError(e, action.name);
}
}
_registerAction(name, menuIndex, info) {
try {
// Device Action
const action = new Gio.SimpleAction({
name: name,
parameter_type: info.parameter_type,
enabled: false,
});
action.connect('activate', this._activateAction.bind(this));
this.device.add_action(action);
// Menu
if (menuIndex > -1) {
this.device.addMenuAction(
action,
menuIndex,
info.label,
info.icon_name
);
}
this._gactions.push(action);
} catch (e) {
logError(e, `${this.device.name}: ${this.name}`);
}
}
/**
* Called when the device connects.
*/
connected() {
// Enabled based on device capabilities, which might change
const incoming = this.device.settings.get_strv('incoming-capabilities');
const outgoing = this.device.settings.get_strv('outgoing-capabilities');
for (const action of this._gactions) {
const info = this._meta.actions[action.name];
if (info.incoming.every(type => outgoing.includes(type)) &&
info.outgoing.every(type => incoming.includes(type)))
action.set_enabled(true);
}
}
/**
* Called when the device disconnects.
*/
disconnected() {
for (const action of this._gactions)
action.set_enabled(false);
}
/**
* Called when a packet is received that the plugin is a handler for.
*
* @param {Core.Packet} packet - A KDE Connect packet
*/
handlePacket(packet) {
throw new GObject.NotImplementedError();
}
/**
* Cache JSON parseable properties on this object for persistence. The
* filename ~/.cache/gsconnect/<device-id>/<plugin-name>.json will be used
* to store the properties and values.
*
* Calling cacheProperties() opens a JSON cache file and reads any stored
* properties and values onto the current instance. When destroy()
* is called the properties are automatically stored in the same file.
*
* @param {Array} names - A list of this object's property names to cache
*/
async cacheProperties(names) {
try {
this._cacheProperties = names;
// Ensure the device's cache directory exists
const cachedir = GLib.build_filenamev([
Config.CACHEDIR,
this.device.id,
]);
GLib.mkdir_with_parents(cachedir, 448);
this._cacheFile = Gio.File.new_for_path(
GLib.build_filenamev([cachedir, `${this.name}.json`]));
// Read the cache from disk
const [contents] = await this._cacheFile.load_contents_async(
this.cancellable);
const cache = JSON.parse(new TextDecoder().decode(contents));
Object.assign(this, cache);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
} finally {
this.cacheLoaded();
}
}
/**
* An overridable function that is invoked when the on-disk cache is being
* cleared. Implementations should use this function to clear any in-memory
* cache data.
*/
clearCache() {}
/**
* An overridable function that is invoked when the cache is done loading
*/
cacheLoaded() {}
/**
* Unregister plugin actions, write the cache (if applicable) and destroy
* any dangling signal handlers.
*/
destroy() {
// Cancel any pending plugin operations
if (this._cancellable !== undefined)
this._cancellable.cancel();
for (const action of this._gactions) {
this.device.removeMenuAction(`device.${action.name}`);
this.device.remove_action(action.name);
}
// Write the cache to disk synchronously
if (this._cacheFile !== undefined) {
try {
// Build the cache
const cache = {};
for (const name of this._cacheProperties)
cache[name] = this[name];
this._cacheFile.replace_contents(
JSON.stringify(cache, null, 2),
null,
false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
null
);
} catch (e) {
debug(e.message, `${this.device.name}: ${this.name}`);
}
}
GObject.signal_handlers_destroy(this);
}
});
export default Plugin;

View File

@@ -0,0 +1,433 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Battery'),
description: _('Exchange battery information'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Battery',
incomingCapabilities: [
'kdeconnect.battery',
'kdeconnect.battery.request',
],
outgoingCapabilities: [
'kdeconnect.battery',
'kdeconnect.battery.request',
],
actions: {},
};
/**
* Battery Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/battery
*/
const BatteryPlugin = GObject.registerClass({
GTypeName: 'GSConnectBatteryPlugin',
}, class BatteryPlugin extends Plugin {
_init(device) {
super._init(device, 'battery');
// Setup Cache; defaults are 90 minute charge, 1 day discharge
this._chargeState = [54, 0, -1];
this._dischargeState = [864, 0, -1];
this._thresholdLevel = 25;
this.cacheProperties([
'_chargeState',
'_dischargeState',
'_thresholdLevel',
]);
// Export battery state as GAction
this.__state = new Gio.SimpleAction({
name: 'battery',
parameter_type: new GLib.VariantType('(bsii)'),
state: this.state,
});
this.device.add_action(this.__state);
// Local Battery (UPower)
this._upower = null;
this._sendStatisticsId = this.settings.connect(
'changed::send-statistics',
this._onSendStatisticsChanged.bind(this)
);
this._onSendStatisticsChanged(this.settings);
}
get charging() {
if (this._charging === undefined)
this._charging = false;
return this._charging;
}
get icon_name() {
let icon;
if (this.level === -1)
return 'battery-missing-symbolic';
else if (this.level === 100)
return 'battery-full-charged-symbolic';
else if (this.level < 3)
icon = 'battery-empty';
else if (this.level < 10)
icon = 'battery-caution';
else if (this.level < 30)
icon = 'battery-low';
else if (this.level < 60)
icon = 'battery-good';
else if (this.level >= 60)
icon = 'battery-full';
if (this.charging)
return `${icon}-charging-symbolic`;
return `${icon}-symbolic`;
}
get level() {
// This is what KDE Connect returns if the remote battery plugin is
// disabled or still being loaded
if (this._level === undefined)
this._level = -1;
return this._level;
}
get time() {
if (this._time === undefined)
this._time = 0;
return this._time;
}
get state() {
return new GLib.Variant(
'(bsii)',
[this.charging, this.icon_name, this.level, this.time]
);
}
cacheLoaded() {
this._initEstimate();
this._sendState();
}
clearCache() {
this._chargeState = [54, 0, -1];
this._dischargeState = [864, 0, -1];
this._thresholdLevel = 25;
this._initEstimate();
}
connected() {
super.connected();
this._requestState();
this._sendState();
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.battery':
this._receiveState(packet);
break;
case 'kdeconnect.battery.request':
this._sendState();
break;
}
}
_onSendStatisticsChanged() {
if (this.settings.get_boolean('send-statistics'))
this._monitorState();
else
this._unmonitorState();
}
/**
* Recalculate and update the estimated time remaining, but not the rate.
*/
_initEstimate() {
let rate, level;
// elision of [rate, time, level]
if (this.charging)
[rate,, level] = this._chargeState;
else
[rate,, level] = this._dischargeState;
if (!Number.isFinite(rate) || rate < 1)
rate = this.charging ? 864 : 90;
if (!Number.isFinite(level) || level < 0)
level = this.level;
// Update the time remaining
if (rate && this.charging)
this._time = Math.floor(rate * (100 - level));
else if (rate && !this.charging)
this._time = Math.floor(rate * level);
this.__state.state = this.state;
}
/**
* Recalculate the (dis)charge rate and update the estimated time remaining.
*/
_updateEstimate() {
let rate, time, level;
const newTime = Math.floor(Date.now() / 1000);
const newLevel = this.level;
// Load the state; ensure we have sane values for calculation
if (this.charging)
[rate, time, level] = this._chargeState;
else
[rate, time, level] = this._dischargeState;
if (!Number.isFinite(rate) || rate < 1)
rate = this.charging ? 54 : 864;
if (!Number.isFinite(time) || time <= 0)
time = newTime;
if (!Number.isFinite(level) || level < 0)
level = newLevel;
// Update the rate; use a weighted average to account for missed changes
// NOTE: (rate = seconds/percent)
const ldiff = this.charging ? newLevel - level : level - newLevel;
const tdiff = newTime - time;
const newRate = tdiff / ldiff;
if (newRate && Number.isFinite(newRate))
rate = Math.floor((rate * 0.4) + (newRate * 0.6));
// Store the state for the next recalculation
if (this.charging)
this._chargeState = [rate, newTime, newLevel];
else
this._dischargeState = [rate, newTime, newLevel];
// Update the time remaining
if (rate && this.charging)
this._time = Math.floor(rate * (100 - newLevel));
else if (rate && !this.charging)
this._time = Math.floor(rate * newLevel);
this.__state.state = this.state;
}
/**
* Notify the user the remote battery is full.
*/
_fullBatteryNotification() {
if (!this.settings.get_boolean('full-battery-notification'))
return;
// Offer the option to ring the device, if available
let buttons = [];
if (this.device.get_action_enabled('ring')) {
buttons = [{
label: _('Ring'),
action: 'ring',
parameter: null,
}];
}
this.device.showNotification({
id: 'battery|full',
// TRANSLATORS: eg. Google Pixel: Battery is full
title: _('%s: Battery is full').format(this.device.name),
// TRANSLATORS: when the battery is fully charged
body: _('Fully Charged'),
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
buttons: buttons,
});
}
/**
* Notify the user the remote battery is at custom charge level.
*/
_customBatteryNotification() {
if (!this.settings.get_boolean('custom-battery-notification'))
return;
// Offer the option to ring the device, if available
let buttons = [];
if (this.device.get_action_enabled('ring')) {
buttons = [{
label: _('Ring'),
action: 'ring',
parameter: null,
}];
}
this.device.showNotification({
id: 'battery|custom',
// TRANSLATORS: eg. Google Pixel: Battery has reached custom charge level
title: _('%s: Battery has reached custom charge level').format(this.device.name),
// TRANSLATORS: when the battery has reached custom charge level
body: _('%d%% Charged').format(this.level),
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
buttons: buttons,
});
}
/**
* Notify the user the remote battery is low.
*/
_lowBatteryNotification() {
if (!this.settings.get_boolean('low-battery-notification'))
return;
// Offer the option to ring the device, if available
let buttons = [];
if (this.device.get_action_enabled('ring')) {
buttons = [{
label: _('Ring'),
action: 'ring',
parameter: null,
}];
}
this.device.showNotification({
id: 'battery|low',
// TRANSLATORS: eg. Google Pixel: Battery is low
title: _('%s: Battery is low').format(this.device.name),
// TRANSLATORS: eg. 15% remaining
body: _('%d%% remaining').format(this.level),
icon: Gio.ThemedIcon.new('battery-caution-symbolic'),
buttons: buttons,
});
}
/**
* Handle a remote battery update.
*
* @param {Core.Packet} packet - A kdeconnect.battery packet
*/
_receiveState(packet) {
// Charging state changed
this._charging = packet.body.isCharging;
// Level changed
if (this._level !== packet.body.currentCharge) {
this._level = packet.body.currentCharge;
// If the level is above the threshold hide the notification
if (this._level > this._thresholdLevel)
this.device.hideNotification('battery|low');
// The level just changed to/from custom level while charging
if ((this._level === this.settings.get_uint('custom-battery-notification-value')) && this._charging)
this._customBatteryNotification();
else
this.device.hideNotification('battery|custom');
// The level just changed to/from full
if (this._level === 100)
this._fullBatteryNotification();
else
this.device.hideNotification('battery|full');
}
// Device considers the level low
if (packet.body.thresholdEvent > 0) {
this._lowBatteryNotification();
this._thresholdLevel = this.level;
}
this._updateEstimate();
}
/**
* Request the remote battery's current state
*/
_requestState() {
this.device.sendPacket({
type: 'kdeconnect.battery.request',
body: {request: true},
});
}
/**
* Report the local battery's current state
*/
_sendState() {
if (this._upower === null || !this._upower.is_present)
return;
this.device.sendPacket({
type: 'kdeconnect.battery',
body: {
currentCharge: this._upower.level,
isCharging: this._upower.charging,
thresholdEvent: this._upower.threshold,
},
});
}
/*
* UPower monitoring methods
*/
_monitorState() {
try {
// Currently only true if the remote device is a desktop (rare)
const incoming = this.device.settings.get_strv('incoming-capabilities');
if (!incoming.includes('kdeconnect.battery'))
return;
this._upower = Components.acquire('upower');
this._upowerId = this._upower.connect(
'changed',
this._sendState.bind(this)
);
this._sendState();
} catch (e) {
logError(e, this.device.name);
this._unmonitorState();
}
}
_unmonitorState() {
try {
if (this._upower === null)
return;
this._upower.disconnect(this._upowerId);
this._upower = Components.release('upower');
} catch (e) {
logError(e, this.device.name);
}
}
destroy() {
this.device.remove_action('battery');
this.settings.disconnect(this._sendStatisticsId);
this._unmonitorState();
super.destroy();
}
});
export default BatteryPlugin;

View File

@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Clipboard'),
description: _('Share the clipboard content'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Clipboard',
incomingCapabilities: [
'kdeconnect.clipboard',
'kdeconnect.clipboard.connect',
],
outgoingCapabilities: [
'kdeconnect.clipboard',
'kdeconnect.clipboard.connect',
],
actions: {
clipboardPush: {
label: _('Clipboard Push'),
icon_name: 'edit-paste-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.clipboard'],
},
clipboardPull: {
label: _('Clipboard Pull'),
icon_name: 'edit-copy-symbolic',
parameter_type: null,
incoming: ['kdeconnect.clipboard'],
outgoing: [],
},
},
};
/**
* Clipboard Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/clipboard
*/
const ClipboardPlugin = GObject.registerClass({
GTypeName: 'GSConnectClipboardPlugin',
}, class ClipboardPlugin extends Plugin {
_init(device) {
super._init(device, 'clipboard');
this._clipboard = Components.acquire('clipboard');
// Watch local clipboard for changes
this._textChangedId = this._clipboard.connect(
'notify::text',
this._onLocalClipboardChanged.bind(this)
);
// Buffer content to allow selective sync
this._localBuffer = this._clipboard.text;
this._localTimestamp = 0;
this._remoteBuffer = null;
}
connected() {
super.connected();
// TODO: if we're not auto-syncing local->remote, but we are doing the
// reverse, it's possible older remote content will end up
// overwriting newer local content.
if (!this.settings.get_boolean('send-content'))
return;
if (this._localBuffer === null && this._localTimestamp === 0)
return;
this.device.sendPacket({
type: 'kdeconnect.clipboard.connect',
body: {
content: this._localBuffer,
timestamp: this._localTimestamp,
},
});
}
handlePacket(packet) {
if (!packet.body.hasOwnProperty('content'))
return;
switch (packet.type) {
case 'kdeconnect.clipboard':
this._handleContent(packet);
break;
case 'kdeconnect.clipboard.connect':
this._handleConnectContent(packet);
break;
}
}
_handleContent(packet) {
this._onRemoteClipboardChanged(packet.body.content);
}
_handleConnectContent(packet) {
if (packet.body.hasOwnProperty('timestamp') &&
packet.body.timestamp > this._localTimestamp)
this._onRemoteClipboardChanged(packet.body.content);
}
/*
* Store the local clipboard content and forward it if enabled
*/
_onLocalClipboardChanged(clipboard, pspec) {
this._localBuffer = clipboard.text;
this._localTimestamp = Date.now();
if (this.settings.get_boolean('send-content'))
this.clipboardPush();
}
/*
* Store the remote clipboard content and apply it if enabled
*/
_onRemoteClipboardChanged(text) {
this._remoteBuffer = text;
if (this.settings.get_boolean('receive-content'))
this.clipboardPull();
}
/**
* Copy to the remote clipboard; called by _onLocalClipboardChanged()
*/
clipboardPush() {
// Don't sync if the clipboard is empty or not text
if (this._localTimestamp === 0)
return;
if (this._remoteBuffer !== this._localBuffer) {
this._remoteBuffer = this._localBuffer;
// If the buffer is %null, the clipboard contains non-text content,
// so we neither clear the remote clipboard nor pass the content
if (this._localBuffer !== null) {
this.device.sendPacket({
type: 'kdeconnect.clipboard',
body: {
content: this._localBuffer,
},
});
}
}
}
/**
* Copy from the remote clipboard; called by _onRemoteClipboardChanged()
*/
clipboardPull() {
if (this._localBuffer !== this._remoteBuffer) {
this._localBuffer = this._remoteBuffer;
this._localTimestamp = Date.now();
this._clipboard.text = this._remoteBuffer;
}
}
destroy() {
if (this._clipboard && this._textChangedId) {
this._clipboard.disconnect(this._textChangedId);
this._clipboard = Components.release('clipboard');
}
super.destroy();
}
});
export default ClipboardPlugin;

View File

@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Connectivity Report'),
description: _('Display connectivity status'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.ConnectivityReport',
incomingCapabilities: [
'kdeconnect.connectivity_report',
],
outgoingCapabilities: [
'kdeconnect.connectivity_report.request',
],
actions: {},
};
/**
* Connectivity Report Plugin
* https://invent.kde.org/network/kdeconnect-kde/-/tree/master/plugins/connectivity_report
*/
const ConnectivityReportPlugin = GObject.registerClass({
GTypeName: 'GSConnectConnectivityReportPlugin',
}, class ConnectivityReportPlugin extends Plugin {
_init(device) {
super._init(device, 'connectivity_report');
// Export connectivity state as GAction
this.__state = new Gio.SimpleAction({
name: 'connectivityReport',
// (
// cellular_network_type,
// cellular_network_type_icon,
// cellular_network_strength(0..4),
// cellular_network_strength_icon,
// )
parameter_type: new GLib.VariantType('(ssis)'),
state: this.state,
});
this.device.add_action(this.__state);
}
get signal_strength() {
if (this._signalStrength === undefined)
this._signalStrength = -1;
return this._signalStrength;
}
get network_type() {
if (this._networkType === undefined)
this._networkType = '';
return this._networkType;
}
get signal_strength_icon_name() {
if (this.signal_strength === 0)
return 'network-cellular-signal-none-symbolic'; // SIGNAL_STRENGTH_NONE_OR_UNKNOWN
else if (this.signal_strength === 1)
return 'network-cellular-signal-weak-symbolic'; // SIGNAL_STRENGTH_POOR
else if (this.signal_strength === 2)
return 'network-cellular-signal-ok-symbolic'; // SIGNAL_STRENGTH_MODERATE
else if (this.signal_strength === 3)
return 'network-cellular-signal-good-symbolic'; // SIGNAL_STRENGTH_GOOD
else if (this.signal_strength >= 4)
return 'network-cellular-signal-excellent-symbolic'; // SIGNAL_STRENGTH_GREAT
return 'network-cellular-offline-symbolic'; // OFF (signal_strength == -1)
}
get network_type_icon_name() {
if (this.network_type === 'GSM' || this.network_type === 'CDMA' || this.network_type === 'iDEN')
return 'network-cellular-2g-symbolic';
else if (this.network_type === 'UMTS' || this.network_type === 'CDMA2000')
return 'network-cellular-3g-symbolic';
else if (this.network_type === 'LTE')
return 'network-cellular-4g-symbolic';
else if (this.network_type === 'EDGE')
return 'network-cellular-edge-symbolic';
else if (this.network_type === 'GPRS')
return 'network-cellular-gprs-symbolic';
else if (this.network_type === 'HSPA')
return 'network-cellular-hspa-symbolic';
else if (this.network_type === '5G')
return 'network-cellular-5g-symbolic';
return 'network-cellular-symbolic';
}
get state() {
return new GLib.Variant(
'(ssis)',
[
this.network_type,
this.network_type_icon_name,
this.signal_strength,
this.signal_strength_icon_name,
]
);
}
connected() {
super.connected();
this._requestState();
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.connectivity_report':
this._receiveState(packet);
break;
}
}
/**
* Handle a remote state update.
*
* @param {Core.Packet} packet - A kdeconnect.connectivity_report packet
*/
_receiveState(packet) {
if (packet.body.signalStrengths) {
// TODO: Only first SIM (subscriptionID) is supported at the moment
const subs = Object.keys(packet.body.signalStrengths);
const firstSub = Math.min.apply(null, subs);
const data = packet.body.signalStrengths[firstSub];
this._networkType = data.networkType;
this._signalStrength = data.signalStrength;
}
// Update DBus state
this.__state.state = this.state;
}
/**
* Request the remote device's connectivity state
*/
_requestState() {
this.device.sendPacket({
type: 'kdeconnect.connectivity_report.request',
body: {},
});
}
destroy() {
this.device.remove_action('connectivity_report');
super.destroy();
}
});
export default ConnectivityReportPlugin;

View File

@@ -0,0 +1,463 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
import Contacts from '../components/contacts.js';
/*
* We prefer libebook's vCard parser if it's available
*/
let EBookContacts;
export const setEBookContacts = (ebook) => { // This function is only for tests to call!
EBookContacts = ebook;
};
try {
EBookContacts = (await import('gi://EBookContacts')).default;
} catch (e) {
EBookContacts = null;
}
export const Metadata = {
label: _('Contacts'),
description: _('Access contacts of the paired device'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts',
incomingCapabilities: [
'kdeconnect.contacts.response_uids_timestamps',
'kdeconnect.contacts.response_vcards',
],
outgoingCapabilities: [
'kdeconnect.contacts.request_all_uids_timestamps',
'kdeconnect.contacts.request_vcards_by_uid',
],
actions: {},
};
/*
* vCard 2.1 Patterns
*/
const VCARD_FOLDING = /\r\n |\r |\n |=\n/g;
const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i;
const VCARD_BASIC = /^([^:;]+):(.+)$/;
const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/;
const VCARD_TYPED_KEY = /item\d{1,2}\./;
const VCARD_TYPED_META = /([a-z]+)=(.*)/i;
/**
* Contacts Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts
*/
const ContactsPlugin = GObject.registerClass({
GTypeName: 'GSConnectContactsPlugin',
}, class ContactsPlugin extends Plugin {
_init(device) {
super._init(device, 'contacts');
this._store = new Contacts(device.id);
this._store.fetch = this._requestUids.bind(this);
// Notify when the store is ready
this._contactsStoreReadyId = this._store.connect(
'notify::context',
() => this.device.notify('contacts')
);
// Notify if the contacts source changes
this._contactsSourceChangedId = this.settings.connect(
'changed::contacts-source',
() => this.device.notify('contacts')
);
// Load the cache
this._store.load();
}
clearCache() {
this._store.clear();
}
connected() {
super.connected();
this._requestUids();
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.contacts.response_uids_timestamps':
this._handleUids(packet);
break;
case 'kdeconnect.contacts.response_vcards':
this._handleVCards(packet);
break;
}
}
_handleUids(packet) {
try {
const contacts = this._store.contacts;
const remote_uids = packet.body.uids;
let removed = false;
delete packet.body.uids;
// Usually a failed request, so avoid wiping the cache
if (remote_uids.length === 0)
return;
// Delete any contacts that were removed on the device
for (let i = 0, len = contacts.length; i < len; i++) {
const contact = contacts[i];
if (!remote_uids.includes(contact.id)) {
this._store.remove(contact.id, false);
removed = true;
}
}
// Build a list of new or updated contacts
const uids = [];
for (const [uid, timestamp] of Object.entries(packet.body)) {
const contact = this._store.get_contact(uid);
if (!contact || contact.timestamp !== timestamp)
uids.push(uid);
}
// Send a request for any new or updated contacts
if (uids.length)
this._requestVCards(uids);
// If we removed any contacts, save the cache
if (removed)
this._store.save();
} catch (e) {
logError(e);
}
}
/**
* Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string
*
* See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js
*
* @param {string} input - The QUOTED-PRINTABLE string
* @return {string} The decoded string
*/
_decodeQuotedPrintable(input) {
return input
// https://tools.ietf.org/html/rfc2045#section-6.7, rule 3
.replace(/[\t\x20]$/gm, '')
// Remove hard line breaks preceded by `=`
.replace(/=(?:\r\n?|\n|$)/g, '')
// https://tools.ietf.org/html/rfc2045#section-6.7, note 1.
.replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => {
const codePoint = parseInt($1, 16);
return String.fromCharCode(codePoint);
});
}
/**
* Decode a string encoded as "UTF-8" and return a regular string
*
* See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js
*
* @param {string} input - The UTF-8 string
* @return {string} The decoded string
*/
_decodeUTF8(input) {
try {
const output = [];
let i = 0;
let c1 = 0;
let seqlen = 0;
while (i < input.length) {
c1 = input.charCodeAt(i) & 0xFF;
seqlen = 0;
if (c1 <= 0xBF) {
c1 &= 0x7F;
seqlen = 1;
} else if (c1 <= 0xDF) {
c1 &= 0x1F;
seqlen = 2;
} else if (c1 <= 0xEF) {
c1 &= 0x0F;
seqlen = 3;
} else {
c1 &= 0x07;
seqlen = 4;
}
for (let ai = 1; ai < seqlen; ++ai)
c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F));
if (seqlen === 4) {
c1 -= 0x10000;
output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)));
output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)));
} else {
output.push(String.fromCharCode(c1));
}
i += seqlen;
}
return output.join('');
// Fallback to old unfaithful
} catch (e) {
try {
return decodeURIComponent(escape(input));
// Say "chowdah" frenchie!
} catch (e) {
debug(e, `Failed to decode UTF-8 VCard field ${input}`);
return input;
}
}
}
/**
* Parse a vCard (v2.1 only) and return a dictionary of the fields
*
* See: http://jsfiddle.net/ARTsinn/P2t2P/
*
* @param {string} vcard_data - The raw VCard data
* @return {Object} dictionary of vCard data
*/
_parseVCard21(vcard_data) {
// vcard skeleton
const vcard = {
fn: _('Unknown Contact'),
tel: [],
};
// Remove line folding and split
const unfolded = vcard_data.replace(VCARD_FOLDING, '');
const lines = unfolded.split(/\r\n|\r|\n/);
for (let i = 0, len = lines.length; i < len; i++) {
const line = lines[i];
let results, key, type, value;
// Empty line or a property we aren't interested in
if (!line || !line.match(VCARD_SUPPORTED))
continue;
// Basic Fields (fn, x-kdeconnect-timestamp, etc)
if ((results = line.match(VCARD_BASIC))) {
[, key, value] = results;
vcard[key.toLowerCase()] = value;
continue;
}
// Typed Fields (tel, adr, etc)
if ((results = line.match(VCARD_TYPED))) {
[, key, type, value] = results;
key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
value = value.split(';');
type = type.split(';');
// Type(s)
const meta = {};
for (let i = 0, len = type.length; i < len; i++) {
const res = type[i].match(VCARD_TYPED_META);
if (res)
meta[res[1]] = res[2];
else
meta[`type${i === 0 ? '' : i}`] = type[i].toLowerCase();
}
// Value(s)
if (vcard[key] === undefined)
vcard[key] = [];
// Decode QUOTABLE-PRINTABLE
if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') {
delete meta.ENCODING;
value = value.map(v => this._decodeQuotedPrintable(v));
}
// Decode UTF-8
if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
delete meta.CHARSET;
value = value.map(v => this._decodeUTF8(v));
}
// Special case for FN (full name)
if (key === 'fn')
vcard[key] = value[0];
else
vcard[key].push({meta: meta, value: value});
}
}
return vcard;
}
/**
* Parse a vCard (v2.1 only) using native JavaScript and add it to the
* contact store.
*
* @param {string} uid - The contact UID
* @param {string} vcard_data - The raw vCard data
*/
async _parseVCardNative(uid, vcard_data) {
try {
const vcard = this._parseVCard21(vcard_data);
const contact = {
id: uid,
name: vcard.fn,
numbers: [],
origin: 'device',
timestamp: parseInt(vcard['x-kdeconnect-timestamp']),
};
// Phone Numbers
contact.numbers = vcard.tel.map(entry => {
let type = 'unknown';
if (entry.meta && entry.meta.type)
type = entry.meta.type;
return {type: type, value: entry.value[0]};
});
// Avatar
if (vcard.photo) {
const data = GLib.base64_decode(vcard.photo[0].value[0]);
contact.avatar = await this._store.storeAvatar(data);
}
this._store.add(contact);
} catch (e) {
debug(e, `Failed to parse VCard contact ${uid}`);
}
}
/**
* Parse a vCard using libebook and add it to the contact store.
*
* @param {string} uid - The contact UID
* @param {string} vcard_data - The raw vCard data
*/
async _parseVCard(uid, vcard_data) {
try {
const contact = {
id: uid,
name: _('Unknown Contact'),
numbers: [],
origin: 'device',
timestamp: 0,
};
const evcard = EBookContacts.VCard.new_from_string(vcard_data);
const attrs = evcard.get_attributes();
for (let i = 0, len = attrs.length; i < len; i++) {
const attr = attrs[i];
let data, number;
switch (attr.get_name().toLowerCase()) {
case 'fn':
contact.name = attr.get_value();
break;
case 'tel':
number = {value: attr.get_value(), type: 'unknown'};
if (attr.has_type('CELL'))
number.type = 'cell';
else if (attr.has_type('HOME'))
number.type = 'home';
else if (attr.has_type('WORK'))
number.type = 'work';
contact.numbers.push(number);
break;
case 'x-kdeconnect-timestamp':
contact.timestamp = parseInt(attr.get_value());
break;
case 'photo':
data = GLib.base64_decode(attr.get_value());
contact.avatar = await this._store.storeAvatar(data);
break;
}
}
this._store.add(contact);
} catch (e) {
debug(e, `Failed to parse VCard contact ${uid}`);
}
}
/**
* Handle an incoming list of contact vCards and pass them to the best
* available parser.
*
* @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards`
*/
_handleVCards(packet) {
try {
// We don't use this
delete packet.body.uids;
// Parse each vCard and add the contact
for (const [uid, vcard] of Object.entries(packet.body)) {
if (EBookContacts)
this._parseVCard(uid, vcard);
else
this._parseVCardNative(uid, vcard);
}
} catch (e) {
logError(e, this.device.name);
}
}
/**
* Request a list of contact UIDs with timestamps.
*/
_requestUids() {
this.device.sendPacket({
type: 'kdeconnect.contacts.request_all_uids_timestamps',
});
}
/**
* Request the vCards for @uids.
*
* @param {string[]} uids - A list of contact UIDs
*/
_requestVCards(uids) {
this.device.sendPacket({
type: 'kdeconnect.contacts.request_vcards_by_uid',
body: {
uids: uids,
},
});
}
destroy() {
this._store.disconnect(this._contactsStoreReadyId);
this.settings.disconnect(this._contactsSourceChangedId);
super.destroy();
}
});
export default ContactsPlugin;

View File

@@ -0,0 +1,249 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Find My Phone'),
description: _('Ring your paired device'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.FindMyPhone',
incomingCapabilities: ['kdeconnect.findmyphone.request'],
outgoingCapabilities: ['kdeconnect.findmyphone.request'],
actions: {
ring: {
label: _('Ring'),
icon_name: 'phonelink-ring-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.findmyphone.request'],
},
},
};
/**
* FindMyPhone Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/findmyphone
*/
const FindMyPhonePlugin = GObject.registerClass({
GTypeName: 'GSConnectFindMyPhonePlugin',
}, class FindMyPhonePlugin extends Plugin {
_init(device) {
super._init(device, 'findmyphone');
this._dialog = null;
this._player = Components.acquire('sound');
this._mixer = Components.acquire('pulseaudio');
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.findmyphone.request':
this._handleRequest();
break;
}
}
/**
* Handle an incoming location request.
*/
_handleRequest() {
try {
// If this is a second request, stop announcing and return
if (this._dialog !== null) {
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
return;
}
this._dialog = new Dialog({
device: this.device,
plugin: this,
});
this._dialog.connect('response', () => {
this._dialog = null;
});
} catch (e) {
this._cancelRequest();
logError(e, this.device.name);
}
}
/**
* Cancel any ongoing ringing and destroy the dialog.
*/
_cancelRequest() {
if (this._dialog !== null)
this._dialog.response(Gtk.ResponseType.DELETE_EVENT);
}
/**
* Request that the remote device announce it's location
*/
ring() {
this.device.sendPacket({
type: 'kdeconnect.findmyphone.request',
body: {},
});
}
destroy() {
this._cancelRequest();
if (this._mixer !== undefined)
this._mixer = Components.release('pulseaudio');
if (this._player !== undefined)
this._player = Components.release('sound');
super.destroy();
}
});
/*
* Used to ensure 'audible-bell' is enabled for fallback
*/
const _WM_SETTINGS = new Gio.Settings({
schema_id: 'org.gnome.desktop.wm.preferences',
path: '/org/gnome/desktop/wm/preferences/',
});
/**
* A custom GtkMessageDialog for alerting of incoming requests
*/
const Dialog = GObject.registerClass({
GTypeName: 'GSConnectFindMyPhoneDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The plugin providing messages',
GObject.ParamFlags.READWRITE,
GObject.Object
),
},
}, class Dialog extends Gtk.MessageDialog {
_init(params) {
super._init({
buttons: Gtk.ButtonsType.CLOSE,
device: params.device,
image: new Gtk.Image({
icon_name: 'phonelink-ring-symbolic',
pixel_size: 512,
halign: Gtk.Align.CENTER,
hexpand: true,
valign: Gtk.Align.CENTER,
vexpand: true,
visible: true,
}),
plugin: params.plugin,
urgency_hint: true,
});
this.set_keep_above(true);
this.maximize();
this.message_area.destroy();
// If an output stream is available start fading the volume up
if (this.plugin._mixer && this.plugin._mixer.output) {
this._stream = this.plugin._mixer.output;
this._previousMuted = this._stream.muted;
this._previousVolume = this._stream.volume;
this._stream.muted = false;
this._stream.fade(0.85, 15);
// Otherwise ensure audible-bell is enabled
} else {
this._previousBell = _WM_SETTINGS.get_boolean('audible-bell');
_WM_SETTINGS.set_boolean('audible-bell', true);
}
// Start the alarm
if (this.plugin._player !== undefined)
this.plugin._player.loopSound('phone-incoming-call', this.cancellable);
// Show the dialog
this.show_all();
}
vfunc_key_press_event(event) {
this.response(Gtk.ResponseType.DELETE_EVENT);
return Gdk.EVENT_STOP;
}
vfunc_motion_notify_event(event) {
this.response(Gtk.ResponseType.DELETE_EVENT);
return Gdk.EVENT_STOP;
}
vfunc_response(response_id) {
// Stop the alarm
this.cancellable.cancel();
// Restore the mixer level
if (this._stream) {
this._stream.muted = this._previousMuted;
this._stream.fade(this._previousVolume);
// Restore the audible-bell
} else {
_WM_SETTINGS.set_boolean('audible-bell', this._previousBell);
}
this.destroy();
}
get cancellable() {
if (this._cancellable === undefined)
this._cancellable = new Gio.Cancellable();
return this._cancellable;
}
get device() {
if (this._device === undefined)
this._device = null;
return this._device;
}
set device(device) {
this._device = device;
}
get plugin() {
if (this._plugin === undefined)
this._plugin = null;
return this._plugin;
}
set plugin(plugin) {
this._plugin = plugin;
}
});
export default FindMyPhonePlugin;

View File

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

View File

@@ -0,0 +1,381 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import {InputDialog} from '../ui/mousepad.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Mousepad'),
description: _('Enables the paired device to act as a remote mouse and keyboard'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Mousepad',
incomingCapabilities: [
'kdeconnect.mousepad.echo',
'kdeconnect.mousepad.request',
'kdeconnect.mousepad.keyboardstate',
],
outgoingCapabilities: [
'kdeconnect.mousepad.echo',
'kdeconnect.mousepad.request',
'kdeconnect.mousepad.keyboardstate',
],
actions: {
keyboard: {
label: _('Remote Input'),
icon_name: 'input-keyboard-symbolic',
parameter_type: null,
incoming: [
'kdeconnect.mousepad.echo',
'kdeconnect.mousepad.keyboardstate',
],
outgoing: ['kdeconnect.mousepad.request'],
},
},
};
/**
* A map of "KDE Connect" keyvals to Gdk
*/
const KeyMap = new Map([
[1, Gdk.KEY_BackSpace],
[2, Gdk.KEY_Tab],
[3, Gdk.KEY_Linefeed],
[4, Gdk.KEY_Left],
[5, Gdk.KEY_Up],
[6, Gdk.KEY_Right],
[7, Gdk.KEY_Down],
[8, Gdk.KEY_Page_Up],
[9, Gdk.KEY_Page_Down],
[10, Gdk.KEY_Home],
[11, Gdk.KEY_End],
[12, Gdk.KEY_Return],
[13, Gdk.KEY_Delete],
[14, Gdk.KEY_Escape],
[15, Gdk.KEY_Sys_Req],
[16, Gdk.KEY_Scroll_Lock],
[17, 0],
[18, 0],
[19, 0],
[20, 0],
[21, Gdk.KEY_F1],
[22, Gdk.KEY_F2],
[23, Gdk.KEY_F3],
[24, Gdk.KEY_F4],
[25, Gdk.KEY_F5],
[26, Gdk.KEY_F6],
[27, Gdk.KEY_F7],
[28, Gdk.KEY_F8],
[29, Gdk.KEY_F9],
[30, Gdk.KEY_F10],
[31, Gdk.KEY_F11],
[32, Gdk.KEY_F12],
]);
const KeyMapCodes = new Map([
[1, 14],
[2, 15],
[3, 101],
[4, 105],
[5, 103],
[6, 106],
[7, 108],
[8, 104],
[9, 109],
[10, 102],
[11, 107],
[12, 28],
[13, 111],
[14, 1],
[15, 99],
[16, 70],
[17, 0],
[18, 0],
[19, 0],
[20, 0],
[21, 59],
[22, 60],
[23, 61],
[24, 62],
[25, 63],
[26, 64],
[27, 65],
[28, 66],
[29, 67],
[30, 68],
[31, 87],
[32, 88],
]);
/**
* Mousepad Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mousepad
*
* TODO: support outgoing mouse events?
*/
const MousepadPlugin = GObject.registerClass({
GTypeName: 'GSConnectMousepadPlugin',
Properties: {
'state': GObject.ParamSpec.boolean(
'state',
'State',
'Remote keyboard state',
GObject.ParamFlags.READABLE,
false
),
},
}, class MousepadPlugin extends Plugin {
_init(device) {
super._init(device, 'mousepad');
if (!globalThis.HAVE_GNOME)
this._input = Components.acquire('ydotool');
else
this._input = Components.acquire('input');
this._shareControlChangedId = this.settings.connect(
'changed::share-control',
this._sendState.bind(this)
);
}
get state() {
if (this._state === undefined)
this._state = false;
return this._state;
}
connected() {
super.connected();
this._sendState();
}
disconnected() {
super.disconnected();
this._state = false;
this.notify('state');
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.mousepad.request':
this._handleInput(packet.body);
break;
case 'kdeconnect.mousepad.echo':
this._handleEcho(packet.body);
break;
case 'kdeconnect.mousepad.keyboardstate':
this._handleState(packet);
break;
}
}
/**
* Handle a input event.
*
* @param {Object} input - The body of a `kdeconnect.mousepad.request`
*/
_handleInput(input) {
if (!this.settings.get_boolean('share-control'))
return;
let keysym;
let modifiers = 0;
const modifiers_codes = [];
// These are ordered, as much as possible, to create the shortest code
// path for high-frequency, low-latency events (eg. mouse movement)
switch (true) {
case input.hasOwnProperty('scroll'):
this._input.scrollPointer(input.dx, input.dy);
break;
case (input.hasOwnProperty('dx') && input.hasOwnProperty('dy')):
this._input.movePointer(input.dx, input.dy);
break;
case (input.hasOwnProperty('key') || input.hasOwnProperty('specialKey')):
// NOTE: \u0000 sometimes sent in advance of a specialKey packet
if (input.key && input.key === '\u0000')
return;
// Modifiers
if (input.alt) {
modifiers |= Gdk.ModifierType.MOD1_MASK;
modifiers_codes.push(56);
}
if (input.ctrl) {
modifiers |= Gdk.ModifierType.CONTROL_MASK;
modifiers_codes.push(29);
}
if (input.shift) {
modifiers |= Gdk.ModifierType.SHIFT_MASK;
modifiers_codes.push(42);
}
if (input.super) {
modifiers |= Gdk.ModifierType.SUPER_MASK;
modifiers_codes.push(125);
}
// Regular key (printable ASCII or Unicode)
if (input.key) {
if (!globalThis.HAVE_GNOME)
this._input.pressKeys(input.key, modifiers_codes);
else
this._input.pressKeys(input.key, modifiers);
this._sendEcho(input);
// Special key (eg. non-printable ASCII)
} else if (input.specialKey && KeyMap.has(input.specialKey)) {
if (!globalThis.HAVE_GNOME) {
keysym = KeyMapCodes.get(input.specialKey);
this._input.pressKeys(keysym, modifiers_codes);
} else {
keysym = KeyMap.get(input.specialKey);
this._input.pressKeys(keysym, modifiers);
}
this._sendEcho(input);
}
break;
case input.hasOwnProperty('singleclick'):
this._input.clickPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('doubleclick'):
this._input.doubleclickPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('middleclick'):
this._input.clickPointer(Gdk.BUTTON_MIDDLE);
break;
case input.hasOwnProperty('rightclick'):
this._input.clickPointer(Gdk.BUTTON_SECONDARY);
break;
case input.hasOwnProperty('singlehold'):
this._input.pressPointer(Gdk.BUTTON_PRIMARY);
break;
case input.hasOwnProperty('singlerelease'):
this._input.releasePointer(Gdk.BUTTON_PRIMARY);
break;
default:
logError(new Error('Unknown input'));
}
}
/**
* Handle an echo/ACK of a event we sent, displaying it the dialog entry.
*
* @param {Object} input - The body of a `kdeconnect.mousepad.echo`
*/
_handleEcho(input) {
if (!this._dialog || !this._dialog.visible)
return;
// Skip modifiers
if (input.alt || input.ctrl || input.super)
return;
if (input.key) {
this._dialog._isAck = true;
this._dialog.entry.buffer.text += input.key;
this._dialog._isAck = false;
} else if (KeyMap.get(input.specialKey) === Gdk.KEY_BackSpace) {
this._dialog.entry.emit('backspace');
}
}
/**
* Handle a state change from the remote keyboard. This is an indication
* that the remote keyboard is ready to accept input.
*
* @param {Object} packet - A `kdeconnect.mousepad.keyboardstate` packet
*/
_handleState(packet) {
this._state = !!packet.body.state;
this.notify('state');
}
/**
* Send an echo/ACK of @input, if requested
*
* @param {Object} input - The body of a 'kdeconnect.mousepad.request'
*/
_sendEcho(input) {
if (!input.sendAck)
return;
delete input.sendAck;
input.isAck = true;
this.device.sendPacket({
type: 'kdeconnect.mousepad.echo',
body: input,
});
}
/**
* Send the local keyboard state
*
* @param {boolean} state - Whether we're ready to accept input
*/
_sendState() {
this.device.sendPacket({
type: 'kdeconnect.mousepad.keyboardstate',
body: {
state: this.settings.get_boolean('share-control'),
},
});
}
/**
* Open the Keyboard Input dialog
*/
keyboard() {
if (this._dialog === undefined) {
this._dialog = new InputDialog({
device: this.device,
plugin: this,
});
}
this._dialog.present();
}
destroy() {
if (this._input !== undefined) {
if (!globalThis.HAVE_GNOME)
this._input = Components.release('ydotool');
else
this._input = Components.release('input');
}
if (this._dialog !== undefined)
this._dialog.destroy();
this.settings.disconnect(this._shareControlChangedId);
super.destroy();
}
});
export default MousepadPlugin;

View File

@@ -0,0 +1,917 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import * as DBus from '../utils/dbus.js';
import {Player} from '../components/mpris.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('MPRIS'),
description: _('Bidirectional remote media playback control'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.MPRIS',
incomingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
outgoingCapabilities: ['kdeconnect.mpris', 'kdeconnect.mpris.request'],
actions: {},
};
/**
* MPRIS Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/mpriscontrol
*
* See also:
* https://specifications.freedesktop.org/mpris-spec/latest/
* https://github.com/GNOME/gnome-shell/blob/master/js/ui/mpris.js
*/
const MPRISPlugin = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlugin',
}, class MPRISPlugin extends Plugin {
_init(device) {
super._init(device, 'mpris');
this._players = new Map();
this._transferring = new WeakSet();
this._updating = new WeakSet();
this._mpris = Components.acquire('mpris');
this._playerAddedId = this._mpris.connect(
'player-added',
this._sendPlayerList.bind(this)
);
this._playerRemovedId = this._mpris.connect(
'player-removed',
this._sendPlayerList.bind(this)
);
this._playerChangedId = this._mpris.connect(
'player-changed',
this._onPlayerChanged.bind(this)
);
this._playerSeekedId = this._mpris.connect(
'player-seeked',
this._onPlayerSeeked.bind(this)
);
}
connected() {
super.connected();
this._requestPlayerList();
this._sendPlayerList();
}
disconnected() {
super.disconnected();
for (const [identity, player] of this._players) {
this._players.delete(identity);
player.destroy();
}
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.mpris':
this._handleUpdate(packet);
break;
case 'kdeconnect.mpris.request':
this._handleRequest(packet);
break;
}
}
/**
* Handle a remote player update.
*
* @param {Core.Packet} packet - A `kdeconnect.mpris`
*/
_handleUpdate(packet) {
try {
if (packet.body.hasOwnProperty('playerList'))
this._handlePlayerList(packet.body.playerList);
else if (packet.body.hasOwnProperty('player'))
this._handlePlayerUpdate(packet);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Handle an updated list of remote players.
*
* @param {string[]} playerList - A list of remote player names
*/
_handlePlayerList(playerList) {
// Destroy removed players before adding new ones
for (const player of this._players.values()) {
if (!playerList.includes(player.Identity)) {
this._players.delete(player.Identity);
player.destroy();
}
}
for (const identity of playerList) {
if (!this._players.has(identity)) {
const player = new PlayerRemote(this.device, identity);
this._players.set(identity, player);
}
// Always request player updates; packets are cheap
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: identity,
requestNowPlaying: true,
requestVolume: true,
},
});
}
}
/**
* Handle an update for a remote player.
*
* @param {Object} packet - A `kdeconnect.mpris` packet
*/
_handlePlayerUpdate(packet) {
const player = this._players.get(packet.body.player);
if (player === undefined)
return;
if (packet.body.hasOwnProperty('transferringAlbumArt'))
player.handleAlbumArt(packet);
else
player.update(packet.body);
}
/**
* Request a list of remote players.
*/
_requestPlayerList() {
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
requestPlayerList: true,
},
});
}
/**
* Handle a request for player information or action.
*
* @param {Core.Packet} packet - a `kdeconnect.mpris.request`
* @return {undefined} no return value
*/
_handleRequest(packet) {
// A request for the list of players
if (packet.body.hasOwnProperty('requestPlayerList'))
return this._sendPlayerList();
// A request for an unknown player; send the list of players
if (!this._mpris.hasPlayer(packet.body.player))
return this._sendPlayerList();
// An album art request
if (packet.body.hasOwnProperty('albumArtUrl'))
return this._sendAlbumArt(packet);
// A player command
this._handleCommand(packet);
}
/**
* Handle an incoming player command or information request
*
* @param {Core.Packet} packet - A `kdeconnect.mpris.request`
*/
async _handleCommand(packet) {
if (!this.settings.get_boolean('share-players'))
return;
let player;
try {
player = this._mpris.getPlayer(packet.body.player);
if (player === undefined || this._updating.has(player))
return;
this._updating.add(player);
// Player Actions
if (packet.body.hasOwnProperty('action')) {
switch (packet.body.action) {
case 'PlayPause':
case 'Play':
case 'Pause':
case 'Next':
case 'Previous':
case 'Stop':
player[packet.body.action]();
break;
default:
debug(`unknown action: ${packet.body.action}`);
}
}
// Player Properties
if (packet.body.hasOwnProperty('setLoopStatus'))
player.LoopStatus = packet.body.setLoopStatus;
if (packet.body.hasOwnProperty('setShuffle'))
player.Shuffle = packet.body.setShuffle;
if (packet.body.hasOwnProperty('setVolume'))
player.Volume = packet.body.setVolume / 100;
if (packet.body.hasOwnProperty('Seek'))
await player.Seek(packet.body.Seek);
if (packet.body.hasOwnProperty('SetPosition')) {
// We want to avoid implementing this as a seek operation,
// because some players seek a fixed amount for every
// seek request, only respecting the sign of the parameter.
// (Chrome, for example, will only seek ±5 seconds, regardless
// what value is passed to Seek().)
const position = packet.body.SetPosition;
const metadata = player.Metadata;
if (metadata.hasOwnProperty('mpris:trackid')) {
const trackId = metadata['mpris:trackid'];
await player.SetPosition(trackId, position * 1000);
} else {
await player.Seek(position * 1000 - player.Position);
}
}
// Information Request
let hasResponse = false;
const response = {
type: 'kdeconnect.mpris',
body: {
player: packet.body.player,
},
};
if (packet.body.hasOwnProperty('requestNowPlaying')) {
hasResponse = true;
Object.assign(response.body, {
pos: Math.floor(player.Position / 1000),
isPlaying: (player.PlaybackStatus === 'Playing'),
canPause: player.CanPause,
canPlay: player.CanPlay,
canGoNext: player.CanGoNext,
canGoPrevious: player.CanGoPrevious,
canSeek: player.CanSeek,
loopStatus: player.LoopStatus,
shuffle: player.Shuffle,
// default values for members that will be filled conditionally
albumArtUrl: '',
length: 0,
artist: '',
title: '',
album: '',
nowPlaying: '',
volume: 0,
});
const metadata = player.Metadata;
if (metadata.hasOwnProperty('mpris:artUrl')) {
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
response.body.albumArtUrl = file.get_uri();
}
if (metadata.hasOwnProperty('mpris:length')) {
const trackLen = Math.floor(metadata['mpris:length'] / 1000);
response.body.length = trackLen;
}
if (metadata.hasOwnProperty('xesam:artist')) {
const artists = metadata['xesam:artist'];
response.body.artist = artists.join(', ');
}
if (metadata.hasOwnProperty('xesam:title'))
response.body.title = metadata['xesam:title'];
if (metadata.hasOwnProperty('xesam:album'))
response.body.album = metadata['xesam:album'];
// Now Playing
if (response.body.artist && response.body.title) {
response.body.nowPlaying = [
response.body.artist,
response.body.title,
].join(' - ');
} else if (response.body.artist) {
response.body.nowPlaying = response.body.artist;
} else if (response.body.title) {
response.body.nowPlaying = response.body.title;
} else {
response.body.nowPlaying = _('Unknown');
}
}
if (packet.body.hasOwnProperty('requestVolume')) {
hasResponse = true;
response.body.volume = Math.floor(player.Volume * 100);
}
if (hasResponse)
this.device.sendPacket(response);
} catch (e) {
debug(e, this.device.name);
} finally {
this._updating.delete(player);
}
}
_onPlayerChanged(mpris, player) {
if (!this.settings.get_boolean('share-players'))
return;
this._handleCommand({
body: {
player: player.Identity,
requestNowPlaying: true,
requestVolume: true,
},
});
}
_onPlayerSeeked(mpris, player, offset) {
// TODO: although we can handle full seeked signals, kdeconnect-android
// does not, and expects a position update instead
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
player: player.Identity,
pos: Math.floor(player.Position / 1000),
// Seek: Math.floor(offset / 1000),
},
});
}
async _sendAlbumArt(packet) {
let player;
try {
// Reject concurrent requests for album art
player = this._mpris.getPlayer(packet.body.player);
if (player === undefined || this._transferring.has(player))
return;
// Ensure the requested albumArtUrl matches the current mpris:artUrl
const metadata = player.Metadata;
if (!metadata.hasOwnProperty('mpris:artUrl'))
return;
const file = Gio.File.new_for_uri(metadata['mpris:artUrl']);
const request = Gio.File.new_for_uri(packet.body.albumArtUrl);
if (file.get_uri() !== request.get_uri())
throw RangeError(`invalid URI "${packet.body.albumArtUrl}"`);
// Transfer the album art
this._transferring.add(player);
const transfer = this.device.createTransfer();
transfer.addFile({
type: 'kdeconnect.mpris',
body: {
transferringAlbumArt: true,
player: packet.body.player,
albumArtUrl: packet.body.albumArtUrl,
},
}, file);
await transfer.start();
} catch (e) {
debug(e, this.device.name);
} finally {
this._transferring.delete(player);
}
}
/**
* Send the list of player identities and indicate whether we support
* transferring album art
*/
_sendPlayerList() {
let playerList = [];
if (this.settings.get_boolean('share-players'))
playerList = this._mpris.getIdentities();
this.device.sendPacket({
type: 'kdeconnect.mpris',
body: {
playerList: playerList,
supportAlbumArtPayload: true,
},
});
}
destroy() {
if (this._mpris !== undefined) {
this._mpris.disconnect(this._playerAddedId);
this._mpris.disconnect(this._playerRemovedId);
this._mpris.disconnect(this._playerChangedId);
this._mpris.disconnect(this._playerSeekedId);
this._mpris = Components.release('mpris');
}
for (const [identity, player] of this._players) {
this._players.delete(identity);
player.destroy();
}
super.destroy();
}
});
/*
* A class for mirroring a remote Media Player on DBus
*/
const PlayerRemote = GObject.registerClass({
GTypeName: 'GSConnectMPRISPlayerRemote',
}, class PlayerRemote extends Player {
_init(device, identity) {
super._init();
this._device = device;
this._Identity = identity;
this._isPlaying = false;
this._artist = null;
this._title = null;
this._album = null;
this._length = 0;
this._artUrl = null;
this._ownerId = 0;
this._connection = null;
this._applicationIface = null;
this._playerIface = null;
}
_getFile(albumArtUrl) {
const hash = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5,
albumArtUrl, -1);
const path = GLib.build_filenamev([Config.CACHEDIR, hash]);
return Gio.File.new_for_uri(`file://${path}`);
}
_requestAlbumArt(state) {
if (this._artUrl === state.albumArtUrl)
return;
const file = this._getFile(state.albumArtUrl);
if (file.query_exists(null)) {
this._artUrl = file.get_uri();
this._Metadata = undefined;
this.notify('Metadata');
} else {
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
albumArtUrl: state.albumArtUrl,
},
});
}
}
_updateMetadata(state) {
let metadataChanged = false;
if (state.hasOwnProperty('artist')) {
if (this._artist !== state.artist) {
this._artist = state.artist;
metadataChanged = true;
}
} else if (this._artist) {
this._artist = null;
metadataChanged = true;
}
if (state.hasOwnProperty('title')) {
if (this._title !== state.title) {
this._title = state.title;
metadataChanged = true;
}
} else if (this._title) {
this._title = null;
metadataChanged = true;
}
if (state.hasOwnProperty('album')) {
if (this._album !== state.album) {
this._album = state.album;
metadataChanged = true;
}
} else if (this._album) {
this._album = null;
metadataChanged = true;
}
if (state.hasOwnProperty('length')) {
if (this._length !== state.length * 1000) {
this._length = state.length * 1000;
metadataChanged = true;
}
} else if (this._length) {
this._length = 0;
metadataChanged = true;
}
if (state.hasOwnProperty('albumArtUrl')) {
this._requestAlbumArt(state);
} else if (this._artUrl) {
this._artUrl = null;
metadataChanged = true;
}
if (metadataChanged) {
this._Metadata = undefined;
this.notify('Metadata');
}
}
async export() {
try {
if (this._connection === null) {
this._connection = await DBus.newConnection();
const MPRISIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2');
const MPRISPlayerIface = Config.DBUS.lookup_interface('org.mpris.MediaPlayer2.Player');
if (this._applicationIface === null) {
this._applicationIface = new DBus.Interface({
g_instance: this,
g_connection: this._connection,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_info: MPRISIface,
});
}
if (this._playerIface === null) {
this._playerIface = new DBus.Interface({
g_instance: this,
g_connection: this._connection,
g_object_path: '/org/mpris/MediaPlayer2',
g_interface_info: MPRISPlayerIface,
});
}
}
if (this._ownerId !== 0)
return;
const name = [
this.device.name,
this.Identity,
].join('').replace(/[\W]*/g, '');
this._ownerId = Gio.bus_own_name_on_connection(
this._connection,
`org.mpris.MediaPlayer2.GSConnect.${name}`,
Gio.BusNameOwnerFlags.NONE,
null,
null
);
} catch (e) {
debug(e, this.Identity);
}
}
unexport() {
if (this._ownerId === 0)
return;
Gio.bus_unown_name(this._ownerId);
this._ownerId = 0;
}
/**
* Download album art for the current track of the remote player.
*
* @param {Core.Packet} packet - A `kdeconnect.mpris` packet
*/
async handleAlbumArt(packet) {
let file;
try {
file = this._getFile(packet.body.albumArtUrl);
// Transfer the album art
const transfer = this.device.createTransfer();
transfer.addFile(packet, file);
await transfer.start();
this._artUrl = file.get_uri();
this._Metadata = undefined;
this.notify('Metadata');
} catch (e) {
debug(e, this.device.name);
if (file)
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
}
}
/**
* Update the internal state of the media player.
*
* @param {Core.Packet} state - The body of a `kdeconnect.mpris` packet
*/
update(state) {
this.freeze_notify();
// Metadata
if (state.hasOwnProperty('nowPlaying') ||
state.hasOwnProperty('artist') ||
state.hasOwnProperty('title'))
this._updateMetadata(state);
// Playback Status
if (state.hasOwnProperty('isPlaying')) {
if (this._isPlaying !== state.isPlaying) {
this._isPlaying = state.isPlaying;
this.notify('PlaybackStatus');
}
}
if (state.hasOwnProperty('canPlay')) {
if (this.CanPlay !== state.canPlay) {
this._CanPlay = state.canPlay;
this.notify('CanPlay');
}
}
if (state.hasOwnProperty('canPause')) {
if (this.CanPause !== state.canPause) {
this._CanPause = state.canPause;
this.notify('CanPause');
}
}
if (state.hasOwnProperty('canGoNext')) {
if (this.CanGoNext !== state.canGoNext) {
this._CanGoNext = state.canGoNext;
this.notify('CanGoNext');
}
}
if (state.hasOwnProperty('canGoPrevious')) {
if (this.CanGoPrevious !== state.canGoPrevious) {
this._CanGoPrevious = state.canGoPrevious;
this.notify('CanGoPrevious');
}
}
if (state.hasOwnProperty('pos'))
this._Position = state.pos * 1000;
if (state.hasOwnProperty('volume')) {
if (this.Volume !== state.volume / 100) {
this._Volume = state.volume / 100;
this.notify('Volume');
}
}
this.thaw_notify();
if (!this._isPlaying && !this.CanControl)
this.unexport();
else
this.export();
}
/*
* Native properties
*/
get device() {
return this._device;
}
/*
* The org.mpris.MediaPlayer2.Player Interface
*/
get CanControl() {
return (this.CanPlay || this.CanPause);
}
get Metadata() {
if (this._Metadata === undefined) {
this._Metadata = {};
if (this._artist) {
this._Metadata['xesam:artist'] = new GLib.Variant('as',
[this._artist]);
}
if (this._title) {
this._Metadata['xesam:title'] = new GLib.Variant('s',
this._title);
}
if (this._album) {
this._Metadata['xesam:album'] = new GLib.Variant('s',
this._album);
}
if (this._artUrl) {
this._Metadata['mpris:artUrl'] = new GLib.Variant('s',
this._artUrl);
}
this._Metadata['mpris:length'] = new GLib.Variant('x',
this._length);
}
return this._Metadata;
}
get PlaybackStatus() {
if (this._isPlaying)
return 'Playing';
return 'Stopped';
}
get Volume() {
if (this._Volume === undefined)
this._Volume = 0.3;
return this._Volume;
}
set Volume(level) {
if (this._Volume === level)
return;
this._Volume = level;
this.notify('Volume');
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
setVolume: Math.floor(this._Volume * 100),
},
});
}
Next() {
if (!this.CanGoNext)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Next',
},
});
}
Pause() {
if (!this.CanPause)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Pause',
},
});
}
Play() {
if (!this.CanPlay)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Play',
},
});
}
PlayPause() {
if (!this.CanPlay && !this.CanPause)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'PlayPause',
},
});
}
Previous() {
if (!this.CanGoPrevious)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Previous',
},
});
}
Seek(offset) {
if (!this.CanSeek)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
Seek: offset,
},
});
}
SetPosition(trackId, position) {
debug(`${this._Identity}: SetPosition(${trackId}, ${position})`);
if (!this.CanControl || !this.CanSeek)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
SetPosition: position / 1000,
},
});
}
Stop() {
if (!this.CanControl)
return;
this.device.sendPacket({
type: 'kdeconnect.mpris.request',
body: {
player: this.Identity,
action: 'Stop',
},
});
}
destroy() {
this.unexport();
if (this._connection) {
this._connection.close(null, null);
this._connection = null;
if (this._applicationIface) {
this._applicationIface.destroy();
this._applicationIface = null;
}
if (this._playerIface) {
this._playerIface.destroy();
this._playerIface = null;
}
}
}
});
export default MPRISPlugin;

View File

@@ -0,0 +1,694 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import Plugin from '../plugin.js';
import ReplyDialog from '../ui/notification.js';
export const Metadata = {
label: _('Notifications'),
description: _('Share notifications with the paired device'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification',
incomingCapabilities: [
'kdeconnect.notification',
'kdeconnect.notification.request',
],
outgoingCapabilities: [
'kdeconnect.notification',
'kdeconnect.notification.action',
'kdeconnect.notification.reply',
'kdeconnect.notification.request',
],
actions: {
withdrawNotification: {
label: _('Cancel Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.notification'],
},
closeNotification: {
label: _('Close Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.notification.request'],
},
replyNotification: {
label: _('Reply Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('(ssa{ss})'),
incoming: ['kdeconnect.notification'],
outgoing: ['kdeconnect.notification.reply'],
},
sendNotification: {
label: _('Send Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('a{sv}'),
incoming: [],
outgoing: ['kdeconnect.notification'],
},
activateNotification: {
label: _('Activate Notification'),
icon_name: 'preferences-system-notifications-symbolic',
parameter_type: new GLib.VariantType('(ss)'),
incoming: [],
outgoing: ['kdeconnect.notification.action'],
},
},
};
// A regex for our custom notificaiton ids
const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/;
// A list of known SMS apps
const SMS_APPS = [
// Popular apps that don't contain the string 'sms'
'com.android.messaging', // AOSP
'com.google.android.apps.messaging', // Google Messages
'com.textra', // Textra
'xyz.klinker.messenger', // Pulse
'com.calea.echo', // Mood Messenger
'com.moez.QKSMS', // QKSMS
'rpkandrodev.yaata', // YAATA
'com.tencent.mm', // WeChat
'com.viber.voip', // Viber
'com.kakao.talk', // KakaoTalk
'com.concentriclivers.mms.com.android.mms', // AOSP Clone
'fr.slvn.mms', // AOSP Clone
'com.promessage.message', //
'com.htc.sense.mms', // HTC Messages
// Known not to work with sms plugin
'org.thoughtcrime.securesms', // Signal Private Messenger
'com.samsung.android.messaging', // Samsung Messages
];
/**
* Try to determine if an notification is from an SMS app
*
* @param {Core.Packet} packet - A `kdeconnect.notification`
* @return {boolean} Whether the notification is from an SMS app
*/
function _isSmsNotification(packet) {
const id = packet.body.id;
if (id.includes('sms'))
return true;
for (let i = 0, len = SMS_APPS.length; i < len; i++) {
if (id.includes(SMS_APPS[i]))
return true;
}
return false;
}
/**
* Remove a local libnotify or Gtk notification.
*
* @param {String|Number} id - Gtk (string) or libnotify id (uint32)
* @param {String|null} application - Application Id if Gtk or null
*/
function _removeNotification(id, application = null) {
let name, path, method, variant;
if (application !== null) {
name = 'org.gtk.Notifications';
method = 'RemoveNotification';
path = '/org/gtk/Notifications';
variant = new GLib.Variant('(ss)', [application, id]);
} else {
name = 'org.freedesktop.Notifications';
path = '/org/freedesktop/Notifications';
method = 'CloseNotification';
variant = new GLib.Variant('(u)', [id]);
}
Gio.DBus.session.call(
name, path, name, method, variant, null,
Gio.DBusCallFlags.NONE, -1, null,
(connection, res) => {
try {
connection.call_finish(res);
} catch (e) {
logError(e);
}
}
);
}
/**
* Notification Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
*/
const NotificationPlugin = GObject.registerClass({
GTypeName: 'GSConnectNotificationPlugin',
}, class NotificationPlugin extends Plugin {
_init(device) {
super._init(device, 'notification');
this._listener = Components.acquire('notification');
this._session = Components.acquire('session');
this._notificationAddedId = this._listener.connect(
'notification-added',
this._onNotificationAdded.bind(this)
);
// Load application notification settings
this._applicationsChangedId = this.settings.connect(
'changed::applications',
this._onApplicationsChanged.bind(this)
);
this._onApplicationsChanged(this.settings, 'applications');
this._applicationsChangedSkip = false;
}
connected() {
super.connected();
this._requestNotifications();
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.notification':
this._handleNotification(packet);
break;
// TODO
case 'kdeconnect.notification.action':
this._handleNotificationAction(packet);
break;
// No Linux/BSD desktop notifications are repliable as yet
case 'kdeconnect.notification.reply':
debug(`Not implemented: ${packet.type}`);
break;
case 'kdeconnect.notification.request':
this._handleNotificationRequest(packet);
break;
default:
debug(`Unknown notification packet: ${packet.type}`);
}
}
_onApplicationsChanged(settings, key) {
if (this._applicationsChangedSkip)
return;
try {
const json = settings.get_string(key);
this._applications = JSON.parse(json);
} catch (e) {
debug(e, this.device.name);
this._applicationsChangedSkip = true;
settings.set_string(key, '{}');
this._applicationsChangedSkip = false;
}
}
_onNotificationAdded(listener, notification) {
try {
const notif = notification.full_unpack();
// An unconfigured application
if (notif.appName && !this._applications[notif.appName]) {
this._applications[notif.appName] = {
iconName: 'system-run-symbolic',
enabled: true,
};
// Store the themed icons for the device preferences window
if (notif.icon === undefined) {
// Keep default
} else if (typeof notif.icon === 'string') {
this._applications[notif.appName].iconName = notif.icon;
} else if (notif.icon instanceof Gio.ThemedIcon) {
const iconName = notif.icon.get_names()[0];
this._applications[notif.appName].iconName = iconName;
}
this._applicationsChangedSkip = true;
this.settings.set_string(
'applications',
JSON.stringify(this._applications)
);
this._applicationsChangedSkip = false;
}
// Sending notifications forbidden
if (!this.settings.get_boolean('send-notifications'))
return;
// Sending when the session is active is forbidden
if (!this.settings.get_boolean('send-active') && this._session.active)
return;
// Notifications disabled for this application
if (notif.appName && !this._applications[notif.appName].enabled)
return;
this.sendNotification(notif);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Handle an incoming notification or closed report.
*
* FIXME: upstream kdeconnect-android is tagging many notifications as
* `silent`, causing them to never be shown. Since we already handle
* duplicates in the Shell, we ignore that flag for now.
*
* @param {Core.Packet} packet - A `kdeconnect.notification`
*/
_handleNotification(packet) {
// A report that a remote notification has been dismissed
if (packet.body.hasOwnProperty('isCancel'))
this.device.hideNotification(packet.body.id);
// A normal, remote notification
else
this._receiveNotification(packet);
}
/**
* Handle an incoming request to activate a notification action.
*
* @param {Core.Packet} packet - A `kdeconnect.notification.action`
*/
_handleNotificationAction(packet) {
throw new GObject.NotImplementedError();
}
/**
* Handle an incoming request to close or list notifications.
*
* @param {Core.Packet} packet - A `kdeconnect.notification.request`
*/
_handleNotificationRequest(packet) {
// A request for our notifications. This isn't implemented and would be
// pretty hard to without communicating with GNOME Shell.
if (packet.body.hasOwnProperty('request'))
return;
// A request to close a local notification
//
// TODO: kdeconnect-android doesn't send these, and will instead send a
// kdeconnect.notification packet with isCancel and an id of "0".
//
// For clients that do support it, we report notification ids in the
// form "type|application-id|notification-id" so we can close it with
// the appropriate service.
if (packet.body.hasOwnProperty('cancel')) {
const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
if (type === 'fdo')
_removeNotification(parseInt(id));
else if (type === 'gtk')
_removeNotification(id, application);
}
}
/**
* Upload an icon from a GLib.Bytes object.
*
* @param {Core.Packet} packet - The packet for the notification
* @param {GLib.Bytes} bytes - The icon bytes
*/
_uploadBytesIcon(packet, bytes) {
const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
this._uploadIconStream(packet, stream, bytes.get_size());
}
/**
* Upload an icon from a Gio.File object.
*
* @param {Core.Packet} packet - A `kdeconnect.notification`
* @param {Gio.File} file - A file object for the icon
*/
async _uploadFileIcon(packet, file) {
const read = file.read_async(GLib.PRIORITY_DEFAULT, null);
const query = file.query_info_async('standard::size',
Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null);
const [stream, info] = await Promise.all([read, query]);
this._uploadIconStream(packet, stream, info.get_size());
}
/**
* A function for uploading GThemedIcons
*
* @param {Core.Packet} packet - The packet for the notification
* @param {Gio.ThemedIcon} icon - The GIcon to upload
*/
_uploadThemedIcon(packet, icon) {
const theme = Gtk.IconTheme.get_default();
let file = null;
for (const name of icon.names) {
// NOTE: kdeconnect-android doesn't support SVGs
const size = Math.max.apply(null, theme.get_icon_sizes(name));
const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);
// Send the first icon we find from the options
if (info) {
file = Gio.File.new_for_path(info.get_filename());
break;
}
}
if (file)
this._uploadFileIcon(packet, file);
else
this.device.sendPacket(packet);
}
/**
* All icon types end up being uploaded in this function.
*
* @param {Core.Packet} packet - The packet for the notification
* @param {Gio.InputStream} stream - A stream to read the icon bytes from
* @param {number} size - Size of the icon in bytes
*/
async _uploadIconStream(packet, stream, size) {
try {
const transfer = this.device.createTransfer();
transfer.addStream(packet, stream, size);
await transfer.start();
} catch (e) {
debug(e);
this.device.sendPacket(packet);
}
}
/**
* Upload an icon from a GIcon or themed icon name.
*
* @param {Core.Packet} packet - A `kdeconnect.notification`
* @param {Gio.Icon|string|null} icon - An icon or %null
* @return {Promise} A promise for the operation
*/
_uploadIcon(packet, icon = null) {
// Normalize strings into GIcons
if (typeof icon === 'string')
icon = Gio.Icon.new_for_string(icon);
if (icon instanceof Gio.ThemedIcon)
return this._uploadThemedIcon(packet, icon);
if (icon instanceof Gio.FileIcon)
return this._uploadFileIcon(packet, icon.get_file());
if (icon instanceof Gio.BytesIcon)
return this._uploadBytesIcon(packet, icon.get_bytes());
return this.device.sendPacket(packet);
}
/**
* Send a local notification to the remote device.
*
* @param {Object} notif - A dictionary of notification parameters
* @param {string} notif.appName - The notifying application
* @param {string} notif.id - The notification ID
* @param {string} notif.title - The notification title
* @param {string} notif.body - The notification body
* @param {string} notif.ticker - The notification title & body
* @param {boolean} notif.isClearable - If the notification can be closed
* @param {string|Gio.Icon} notif.icon - An icon name or GIcon
*/
async sendNotification(notif) {
try {
const icon = notif.icon || null;
delete notif.icon;
await this._uploadIcon({
type: 'kdeconnect.notification',
body: notif,
}, icon);
} catch (e) {
logError(e);
}
}
async _downloadIcon(packet) {
try {
if (!packet.hasPayload())
return null;
// Save the file in the global cache
const path = GLib.build_filenamev([
Config.CACHEDIR,
packet.body.payloadHash || `${Date.now()}`,
]);
// Check if we've already downloaded this icon
// NOTE: if we reject the transfer kdeconnect-android will resend
// the notification packet, which may cause problems wrt #789
const file = Gio.File.new_for_path(path);
if (file.query_exists(null))
return new Gio.FileIcon({file: file});
// Open the target path and create a transfer
const transfer = this.device.createTransfer();
transfer.addFile(packet, file);
try {
await transfer.start();
return new Gio.FileIcon({file: file});
} catch (e) {
debug(e, this.device.name);
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
return null;
}
} catch (e) {
debug(e, this.device.name);
return null;
}
}
/**
* Receive an incoming notification.
*
* @param {Core.Packet} packet - A `kdeconnect.notification`
*/
async _receiveNotification(packet) {
try {
// Set defaults
let action = null;
let buttons = [];
let id = packet.body.id;
let title = packet.body.appName;
let body = `${packet.body.title}: ${packet.body.text}`;
let icon = await this._downloadIcon(packet);
// Repliable Notification
if (packet.body.requestReplyId) {
id = `${packet.body.id}|${packet.body.requestReplyId}`;
action = {
name: 'replyNotification',
parameter: new GLib.Variant('(ssa{ss})', [
packet.body.requestReplyId,
'',
{
appName: packet.body.appName,
title: packet.body.title,
text: packet.body.text,
},
]),
};
}
// Notification Actions
if (packet.body.actions) {
buttons = packet.body.actions.map(action => {
return {
label: action,
action: 'activateNotification',
parameter: new GLib.Variant('(ss)', [id, action]),
};
});
}
// Special case for Missed Calls
if (packet.body.id.includes('MissedCall')) {
title = packet.body.title;
body = packet.body.text;
if (icon === null)
icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});
// Special case for SMS notifications
} else if (_isSmsNotification(packet)) {
title = packet.body.title;
body = packet.body.text;
action = {
name: 'replySms',
parameter: new GLib.Variant('s', packet.body.title),
};
if (icon === null)
icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
// Special case where 'appName' is the same as 'title'
} else if (packet.body.appName === packet.body.title) {
body = packet.body.text;
}
// Use the device icon if we still don't have one
if (icon === null)
icon = new Gio.ThemedIcon({name: this.device.icon_name});
// Show the notification
this.device.showNotification({
id: id,
title: title,
body: body,
icon: icon,
action: action,
buttons: buttons,
});
} catch (e) {
logError(e);
}
}
/**
* Request the remote notifications be sent
*/
_requestNotifications() {
this.device.sendPacket({
type: 'kdeconnect.notification.request',
body: {request: true},
});
}
/**
* Report that a local notification has been closed/dismissed.
* TODO: kdeconnect-android doesn't handle incoming isCancel packets.
*
* @param {string} id - The local notification id
*/
withdrawNotification(id) {
this.device.sendPacket({
type: 'kdeconnect.notification',
body: {
isCancel: true,
id: id,
},
});
}
/**
* Close a remote notification.
* TODO: ignore local notifications
*
* @param {string} id - The remote notification id
*/
closeNotification(id) {
this.device.sendPacket({
type: 'kdeconnect.notification.request',
body: {cancel: id},
});
}
/**
* Reply to a notification sent with a requestReplyId UUID
*
* @param {string} uuid - The requestReplyId for the repliable notification
* @param {string} message - The message to reply with
* @param {Object} notification - The original notification packet
*/
replyNotification(uuid, message, notification) {
// If this happens for some reason, things will explode
if (!uuid)
throw Error('Missing UUID');
// If the message has no content, open a dialog for the user to add one
if (!message) {
const dialog = new ReplyDialog({
device: this.device,
uuid: uuid,
notification: notification,
plugin: this,
});
dialog.present();
// Otherwise just send the reply
} else {
this.device.sendPacket({
type: 'kdeconnect.notification.reply',
body: {
requestReplyId: uuid,
message: message,
},
});
}
}
/**
* Activate a remote notification action
*
* @param {string} id - The remote notification id
* @param {string} action - The notification action (label)
*/
activateNotification(id, action) {
this.device.sendPacket({
type: 'kdeconnect.notification.action',
body: {
action: action,
key: id,
},
});
}
destroy() {
this.settings.disconnect(this._applicationsChangedId);
if (this._listener !== undefined) {
this._listener.disconnect(this._notificationAddedId);
this._listener = Components.release('notification');
}
if (this._session !== undefined)
this._session = Components.release('session');
super.destroy();
}
});
export default NotificationPlugin;

View File

@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Ping'),
description: _('Send and receive pings'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Ping',
incomingCapabilities: ['kdeconnect.ping'],
outgoingCapabilities: ['kdeconnect.ping'],
actions: {
ping: {
label: _('Ping'),
icon_name: 'dialog-information-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.ping'],
},
},
};
/**
* Ping Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/ping
*/
const PingPlugin = GObject.registerClass({
GTypeName: 'GSConnectPingPlugin',
}, class PingPlugin extends Plugin {
_init(device) {
super._init(device, 'ping');
}
handlePacket(packet) {
// Notification
const notif = {
title: this.device.name,
body: _('Ping'),
icon: new Gio.ThemedIcon({name: `${this.device.icon_name}`}),
};
if (packet.body.message) {
// TRANSLATORS: An optional message accompanying a ping, rarely if ever used
// eg. Ping: A message sent with ping
notif.body = _('Ping: %s').format(packet.body.message);
}
this.device.showNotification(notif);
}
ping(message = '') {
const packet = {
type: 'kdeconnect.ping',
body: {},
};
if (message.length)
packet.body.message = message;
this.device.sendPacket(packet);
}
});
export default PingPlugin;

View File

@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Presentation'),
description: _('Use the paired device as a presenter'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Presenter',
incomingCapabilities: ['kdeconnect.presenter'],
outgoingCapabilities: [],
actions: {},
};
/**
* Presenter Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/presenter
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/PresenterPlugin/
*/
const PresenterPlugin = GObject.registerClass({
GTypeName: 'GSConnectPresenterPlugin',
}, class PresenterPlugin extends Plugin {
_init(device) {
super._init(device, 'presenter');
if (!globalThis.HAVE_GNOME)
this._input = Components.acquire('ydotool');
else
this._input = Components.acquire('input');
}
handlePacket(packet) {
if (packet.body.hasOwnProperty('dx')) {
this._input.movePointer(
packet.body.dx * 1000,
packet.body.dy * 1000
);
} else if (packet.body.stop) {
// Currently unsupported and unnecessary as we just re-use the mouse
// pointer instead of showing an arbitrary window.
}
}
destroy() {
if (this._input !== undefined) {
if (!globalThis.HAVE_GNOME)
this._input = Components.release('ydotool');
else
this._input = Components.release('input');
}
super.destroy();
}
});
export default PresenterPlugin;

View File

@@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Run Commands'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.RunCommand',
description: _('Run commands on your paired device or let the device run predefined commands on this PC'),
incomingCapabilities: [
'kdeconnect.runcommand',
'kdeconnect.runcommand.request',
],
outgoingCapabilities: [
'kdeconnect.runcommand',
'kdeconnect.runcommand.request',
],
actions: {
commands: {
label: _('Commands'),
icon_name: 'system-run-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: ['kdeconnect.runcommand'],
outgoing: ['kdeconnect.runcommand.request'],
},
executeCommand: {
label: _('Commands'),
icon_name: 'system-run-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: ['kdeconnect.runcommand'],
outgoing: ['kdeconnect.runcommand.request'],
},
},
};
/**
* RunCommand Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/remotecommands
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/runcommand
*/
const RunCommandPlugin = GObject.registerClass({
GTypeName: 'GSConnectRunCommandPlugin',
Properties: {
'remote-commands': GObject.param_spec_variant(
'remote-commands',
'Remote Command List',
'A list of the device\'s remote commands',
new GLib.VariantType('a{sv}'),
null,
GObject.ParamFlags.READABLE
),
},
}, class RunCommandPlugin extends Plugin {
_init(device) {
super._init(device, 'runcommand');
// Local Commands
this._commandListChangedId = this.settings.connect(
'changed::command-list',
this._sendCommandList.bind(this)
);
// We cache remote commands so they can be used in the settings even
// when the device is offline.
this._remote_commands = {};
this.cacheProperties(['_remote_commands']);
}
get remote_commands() {
return this._remote_commands;
}
connected() {
super.connected();
this._sendCommandList();
this._requestCommandList();
this._handleCommandList(this.remote_commands);
}
clearCache() {
this._remote_commands = {};
this.notify('remote-commands');
}
cacheLoaded() {
if (!this.device.connected)
return;
this._sendCommandList();
this._requestCommandList();
this._handleCommandList(this.remote_commands);
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.runcommand':
this._handleCommandList(packet.body.commandList);
break;
case 'kdeconnect.runcommand.request':
if (packet.body.hasOwnProperty('key'))
this._handleCommand(packet.body.key);
else if (packet.body.hasOwnProperty('requestCommandList'))
this._sendCommandList();
break;
}
}
/**
* Handle a request to execute the local command with the UUID @key
*
* @param {string} key - The UUID of the local command
*/
_handleCommand(key) {
try {
const commands = this.settings.get_value('command-list');
const commandList = commands.recursiveUnpack();
if (!commandList.hasOwnProperty(key)) {
throw new Gio.IOErrorEnum({
code: Gio.IOErrorEnum.PERMISSION_DENIED,
message: `Unknown command: ${key}`,
});
}
this.device.launchProcess([
'/bin/sh',
'-c',
commandList[key].command,
]);
} catch (e) {
logError(e, this.device.name);
}
}
/**
* Parse the response to a request for the remote command list. Remove the
* command menu if there are no commands, otherwise amend the menu.
*
* @param {string|Object[]} commandList - A list of remote commands
*/
_handleCommandList(commandList) {
// See: https://github.com/GSConnect/gnome-shell-extension-gsconnect/issues/1051
if (typeof commandList === 'string') {
try {
commandList = JSON.parse(commandList);
} catch (e) {
commandList = {};
}
}
this._remote_commands = commandList;
this.notify('remote-commands');
const commandEntries = Object.entries(this.remote_commands);
// If there are no commands, hide the menu by disabling the action
this.device.lookup_action('commands').enabled = (commandEntries.length > 0);
// Commands Submenu
const submenu = new Gio.Menu();
for (const [uuid, info] of commandEntries) {
const item = new Gio.MenuItem();
item.set_label(info.name);
item.set_icon(
new Gio.ThemedIcon({name: 'application-x-executable-symbolic'})
);
item.set_detailed_action(`device.executeCommand::${uuid}`);
submenu.append_item(item);
}
// Commands Item
const item = new Gio.MenuItem();
item.set_detailed_action('device.commands::menu');
item.set_attribute_value(
'hidden-when',
new GLib.Variant('s', 'action-disabled')
);
item.set_icon(new Gio.ThemedIcon({name: 'system-run-symbolic'}));
item.set_label(_('Commands'));
item.set_submenu(submenu);
// If the submenu item is already present it will be replaced
const menuActions = this.device.settings.get_strv('menu-actions');
const index = menuActions.indexOf('commands');
if (index > -1) {
this.device.removeMenuAction('device.commands');
this.device.addMenuItem(item, index);
}
}
/**
* Send a request for the remote command list
*/
_requestCommandList() {
this.device.sendPacket({
type: 'kdeconnect.runcommand.request',
body: {requestCommandList: true},
});
}
/**
* Send the local command list
*/
_sendCommandList() {
const commands = this.settings.get_value('command-list').recursiveUnpack();
const commandList = JSON.stringify(commands);
this.device.sendPacket({
type: 'kdeconnect.runcommand',
body: {commandList: commandList},
});
}
/**
* Placeholder function for command action
*/
commands() {}
/**
* Send a request to execute the remote command with the UUID @key
*
* @param {string} key - The UUID of the remote command
*/
executeCommand(key) {
this.device.sendPacket({
type: 'kdeconnect.runcommand.request',
body: {key: key},
});
}
destroy() {
this.settings.disconnect(this._commandListChangedId);
super.destroy();
}
});
export default RunCommandPlugin;

View File

@@ -0,0 +1,487 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../../config.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('SFTP'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
description: _('Browse the paired device filesystem'),
incomingCapabilities: ['kdeconnect.sftp'],
outgoingCapabilities: ['kdeconnect.sftp.request'],
actions: {
mount: {
label: _('Mount'),
icon_name: 'folder-remote-symbolic',
parameter_type: null,
incoming: ['kdeconnect.sftp'],
outgoing: ['kdeconnect.sftp.request'],
},
unmount: {
label: _('Unmount'),
icon_name: 'media-eject-symbolic',
parameter_type: null,
incoming: ['kdeconnect.sftp'],
outgoing: ['kdeconnect.sftp.request'],
},
},
};
const MAX_MOUNT_DIRS = 12;
/**
* SFTP Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
*/
const SFTPPlugin = GObject.registerClass({
GTypeName: 'GSConnectSFTPPlugin',
}, class SFTPPlugin extends Plugin {
_init(device) {
super._init(device, 'sftp');
this._gmount = null;
this._mounting = false;
// A reusable launcher for ssh processes
this._launcher = new Gio.SubprocessLauncher({
flags: (Gio.SubprocessFlags.STDOUT_PIPE |
Gio.SubprocessFlags.STDERR_MERGE),
});
// Watch the volume monitor
this._volumeMonitor = Gio.VolumeMonitor.get();
this._mountAddedId = this._volumeMonitor.connect(
'mount-added',
this._onMountAdded.bind(this)
);
this._mountRemovedId = this._volumeMonitor.connect(
'mount-removed',
this._onMountRemoved.bind(this)
);
}
get gmount() {
if (this._gmount === null && this.device.connected) {
const host = this.device.channel.host;
const regex = new RegExp(
`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
);
for (const mount of this._volumeMonitor.get_mounts()) {
const uri = mount.get_root().get_uri();
if (regex.test(uri)) {
this._gmount = mount;
this._addSubmenu(mount);
this._addSymlink(mount);
break;
}
}
}
return this._gmount;
}
connected() {
super.connected();
// Only enable for Lan connections
if (this.device.channel.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
if (this.settings.get_boolean('automount'))
this.mount();
} else {
this.device.lookup_action('mount').enabled = false;
this.device.lookup_action('unmount').enabled = false;
}
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.sftp':
if (packet.body.hasOwnProperty('errorMessage'))
this._handleError(packet);
else
this._handleMount(packet);
break;
}
}
_onMountAdded(monitor, mount) {
if (this._gmount !== null || !this.device.connected)
return;
const host = this.device.channel.host;
const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
const uri = mount.get_root().get_uri();
if (!regex.test(uri))
return;
this._gmount = mount;
this._addSubmenu(mount);
this._addSymlink(mount);
}
_onMountRemoved(monitor, mount) {
if (this.gmount !== mount)
return;
this._gmount = null;
this._removeSubmenu();
}
async _listDirectories(mount) {
const file = mount.get_root();
const iter = await file.enumerate_children_async(
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
this.cancellable);
const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
GLib.PRIORITY_DEFAULT, this.cancellable);
iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
const directories = {};
for (const info of infos) {
const name = info.get_name();
directories[name] = `${file.get_uri()}${name}/`;
}
return directories;
}
_onAskQuestion(op, message, choices) {
op.reply(Gio.MountOperationResult.HANDLED);
}
_onAskPassword(op, message, user, domain, flags) {
op.reply(Gio.MountOperationResult.HANDLED);
}
/**
* Handle an error reported by the remote device.
*
* @param {Core.Packet} packet - a `kdeconnect.sftp`
*/
_handleError(packet) {
this.device.showNotification({
id: 'sftp-error',
title: _('%s reported an error').format(this.device.name),
body: packet.body.errorMessage,
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
priority: Gio.NotificationPriority.HIGH,
});
}
/**
* Mount the remote device using the provided information.
*
* @param {Core.Packet} packet - a `kdeconnect.sftp`
*/
async _handleMount(packet) {
try {
// Already mounted or mounting
if (this.gmount !== null || this._mounting)
return;
this._mounting = true;
// Ensure the private key is in the keyring
await this._addPrivateKey();
// Create a new mount operation
const op = new Gio.MountOperation({
username: packet.body.user || null,
password: packet.body.password || null,
password_save: Gio.PasswordSave.NEVER,
});
op.connect('ask-question', this._onAskQuestion);
op.connect('ask-password', this._onAskPassword);
// This is the actual call to mount the device
const host = this.device.channel.host;
const uri = `sftp://${host}:${packet.body.port}/`;
const file = Gio.File.new_for_uri(uri);
await file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
this.cancellable);
} catch (e) {
// Special case when the GMount didn't unmount properly but is still
// on the same port and can be reused.
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED))
return;
// There's a good chance this is a host key verification error;
// regardless we'll remove the key for security.
this._removeHostKey(this.device.channel.host);
logError(e, this.device.name);
} finally {
this._mounting = false;
}
}
/**
* Add GSConnect's private key identity to the authentication agent so our
* identity can be verified by Android during private key authentication.
*
* @return {Promise} A promise for the operation
*/
async _addPrivateKey() {
const ssh_add = this._launcher.spawnv([
Config.SSHADD_PATH,
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
]);
const [stdout] = await ssh_add.communicate_utf8_async(null,
this.cancellable);
if (ssh_add.get_exit_status() !== 0)
debug(stdout.trim(), this.device.name);
}
/**
* Remove all host keys from ~/.ssh/known_hosts for @host in the port range
* used by KDE Connect (1739-1764).
*
* @param {string} host - A hostname or IP address
*/
async _removeHostKey(host) {
for (let port = 1739; port <= 1764; port++) {
try {
const ssh_keygen = this._launcher.spawnv([
Config.SSHKEYGEN_PATH,
'-R',
`[${host}]:${port}`,
]);
const [stdout] = await ssh_keygen.communicate_utf8_async(null,
this.cancellable);
const status = ssh_keygen.get_exit_status();
if (status !== 0) {
throw new Gio.IOErrorEnum({
code: Gio.io_error_from_errno(status),
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
});
}
} catch (e) {
logError(e, this.device.name);
}
}
}
/*
* Mount menu helpers
*/
_getUnmountSection() {
if (this._unmountSection === undefined) {
this._unmountSection = new Gio.Menu();
const unmountItem = new Gio.MenuItem();
unmountItem.set_label(Metadata.actions.unmount.label);
unmountItem.set_icon(new Gio.ThemedIcon({
name: Metadata.actions.unmount.icon_name,
}));
unmountItem.set_detailed_action('device.unmount');
this._unmountSection.append_item(unmountItem);
}
return this._unmountSection;
}
_getFilesMenuItem() {
if (this._filesMenuItem === undefined) {
// Files menu icon
const emblem = new Gio.Emblem({
icon: new Gio.ThemedIcon({name: 'emblem-default'}),
});
const mountedIcon = new Gio.EmblemedIcon({
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
});
mountedIcon.add_emblem(emblem);
// Files menu item
this._filesMenuItem = new Gio.MenuItem();
this._filesMenuItem.set_detailed_action('device.mount');
this._filesMenuItem.set_icon(mountedIcon);
this._filesMenuItem.set_label(_('Files'));
}
return this._filesMenuItem;
}
async _addSubmenu(mount) {
try {
const directories = await this._listDirectories(mount);
// Submenu sections
const dirSection = new Gio.Menu();
const unmountSection = this._getUnmountSection();
for (const [name, uri] of Object.entries(directories))
dirSection.append(name, `device.openPath::${uri}`);
// Files submenu
const filesSubmenu = new Gio.Menu();
filesSubmenu.append_section(null, dirSection);
filesSubmenu.append_section(null, unmountSection);
// Files menu item
const filesMenuItem = this._getFilesMenuItem();
filesMenuItem.set_submenu(filesSubmenu);
// Replace the existing menu item
const index = this.device.removeMenuAction('device.mount');
this.device.addMenuItem(filesMenuItem, index);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
debug(e, this.device.name);
// Reset to allow retrying
this._gmount = null;
}
}
_removeSubmenu() {
try {
const index = this.device.removeMenuAction('device.mount');
const action = this.device.lookup_action('mount');
if (action !== null) {
this.device.addMenuAction(
action,
index,
Metadata.actions.mount.label,
Metadata.actions.mount.icon_name
);
}
} catch (e) {
logError(e, this.device.name);
}
}
/**
* Create a symbolic link referring to the device by name
*
* @param {Gio.Mount} mount - A GMount to link to
*/
async _addSymlink(mount) {
try {
const by_name_dir = Gio.File.new_for_path(
`${Config.RUNTIMEDIR}/by-name/`
);
try {
by_name_dir.make_directory_with_parents(null);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
throw e;
}
// Replace path separator with a Unicode lookalike:
let safe_device_name = this.device.name.replace('/', '');
if (safe_device_name === '.')
safe_device_name = '·';
else if (safe_device_name === '..')
safe_device_name = '··';
const link_target = mount.get_root().get_path();
const link = Gio.File.new_for_path(
`${by_name_dir.get_path()}/${safe_device_name}`);
// Check for and remove any existing stale link
try {
const link_stat = await link.query_info_async(
'standard::symlink-target',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
this.cancellable);
if (link_stat.get_symlink_target() === link_target)
return;
await link.delete_async(GLib.PRIORITY_DEFAULT,
this.cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
throw e;
}
link.make_symbolic_link(link_target, this.cancellable);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Send a request to mount the remote device
*/
mount() {
if (this.gmount !== null)
return;
this.device.sendPacket({
type: 'kdeconnect.sftp.request',
body: {
startBrowsing: true,
},
});
}
/**
* Remove the menu items, unmount the filesystem, replace the mount item
*/
async unmount() {
try {
if (this.gmount === null)
return;
this._removeSubmenu();
this._mounting = false;
await this.gmount.unmount_with_operation(
Gio.MountUnmountFlags.FORCE,
new Gio.MountOperation(),
this.cancellable);
} catch (e) {
debug(e, this.device.name);
}
}
destroy() {
if (this._volumeMonitor) {
this._volumeMonitor.disconnect(this._mountAddedId);
this._volumeMonitor.disconnect(this._mountRemovedId);
this._volumeMonitor = null;
}
super.destroy();
}
});
export default SFTPPlugin;

View File

@@ -0,0 +1,492 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import Plugin from '../plugin.js';
import * as URI from '../utils/uri.js';
export const Metadata = {
label: _('Share'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share',
description: _('Share files and URLs between devices'),
incomingCapabilities: ['kdeconnect.share.request'],
outgoingCapabilities: ['kdeconnect.share.request'],
actions: {
share: {
label: _('Share'),
icon_name: 'send-to-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.share.request'],
},
shareFile: {
label: _('Share File'),
icon_name: 'document-send-symbolic',
parameter_type: new GLib.VariantType('(sb)'),
incoming: [],
outgoing: ['kdeconnect.share.request'],
},
shareText: {
label: _('Share Text'),
icon_name: 'send-to-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.share.request'],
},
shareUri: {
label: _('Share Link'),
icon_name: 'send-to-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.share.request'],
},
},
};
/**
* Share Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/share
*
* TODO: receiving 'text' TODO: Window with textview & 'Copy to Clipboard..
* https://github.com/KDE/kdeconnect-kde/commit/28f11bd5c9a717fb9fbb3f02ddd6cea62021d055
*/
const SharePlugin = GObject.registerClass({
GTypeName: 'GSConnectSharePlugin',
}, class SharePlugin extends Plugin {
_init(device) {
super._init(device, 'share');
}
handlePacket(packet) {
// TODO: composite jobs (lastModified, numberOfFiles, totalPayloadSize)
if (packet.body.hasOwnProperty('filename')) {
if (this.settings.get_boolean('receive-files'))
this._handleFile(packet);
else
this._refuseFile(packet);
} else if (packet.body.hasOwnProperty('text')) {
this._handleText(packet);
} else if (packet.body.hasOwnProperty('url')) {
this._handleUri(packet);
}
}
_ensureReceiveDirectory() {
let receiveDir = this.settings.get_string('receive-directory');
// Ensure a directory is set
if (receiveDir.length === 0) {
receiveDir = GLib.get_user_special_dir(
GLib.UserDirectory.DIRECTORY_DOWNLOAD
);
// Fallback to ~/Downloads
const homeDir = GLib.get_home_dir();
if (!receiveDir || receiveDir === homeDir)
receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
this.settings.set_string('receive-directory', receiveDir);
}
// Ensure the directory exists
if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR))
GLib.mkdir_with_parents(receiveDir, 448);
return receiveDir;
}
_getFile(filename) {
const dirpath = this._ensureReceiveDirectory();
const basepath = GLib.build_filenamev([dirpath, filename]);
let filepath = basepath;
let copyNum = 0;
while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
filepath = `${basepath} (${++copyNum})`;
return Gio.File.new_for_path(filepath);
}
_refuseFile(packet) {
try {
this.device.rejectTransfer(packet);
this.device.showNotification({
id: `${Date.now()}`,
title: _('Transfer Failed'),
// TRANSLATORS: eg. Google Pixel is not allowed to upload files
body: _('%s is not allowed to upload files').format(
this.device.name
),
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
});
} catch (e) {
debug(e, this.device.name);
}
}
async _handleFile(packet) {
try {
const file = this._getFile(packet.body.filename);
// Create the transfer
const transfer = this.device.createTransfer();
transfer.addFile(packet, file);
// Notify that we're about to start the transfer
this.device.showNotification({
id: transfer.uuid,
title: _('Transferring File'),
// TRANSLATORS: eg. Receiving 'book.pdf' from Google Pixel
body: _('Receiving “%s” from %s').format(
packet.body.filename,
this.device.name
),
buttons: [{
label: _('Cancel'),
action: 'cancelTransfer',
parameter: new GLib.Variant('s', transfer.uuid),
}],
icon: new Gio.ThemedIcon({name: 'document-save-symbolic'}),
});
// We'll show a notification (success or failure)
let title, body, action, iconName;
let buttons = [];
try {
await transfer.start();
title = _('Transfer Successful');
// TRANSLATORS: eg. Received 'book.pdf' from Google Pixel
body = _('Received “%s” from %s').format(
packet.body.filename,
this.device.name
);
action = {
name: 'showPathInFolder',
parameter: new GLib.Variant('s', file.get_uri()),
};
buttons = [
{
label: _('Show File Location'),
action: 'showPathInFolder',
parameter: new GLib.Variant('s', file.get_uri()),
},
{
label: _('Open File'),
action: 'openPath',
parameter: new GLib.Variant('s', file.get_uri()),
},
];
iconName = 'document-save-symbolic';
if (packet.body.open) {
const uri = file.get_uri();
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
} catch (e) {
debug(e, this.device.name);
title = _('Transfer Failed');
// TRANSLATORS: eg. Failed to receive 'book.pdf' from Google Pixel
body = _('Failed to receive “%s” from %s').format(
packet.body.filename,
this.device.name
);
iconName = 'dialog-warning-symbolic';
// Clean up the downloaded file on failure
file.delete_async(GLib.PRIORITY_DEAFAULT, null, null);
}
this.device.hideNotification(transfer.uuid);
this.device.showNotification({
id: transfer.uuid,
title: title,
body: body,
action: action,
buttons: buttons,
icon: new Gio.ThemedIcon({name: iconName}),
});
} catch (e) {
logError(e, this.device.name);
}
}
_handleUri(packet) {
const uri = packet.body.url;
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
_handleText(packet) {
const dialog = new Gtk.MessageDialog({
text: _('Text Shared By %s').format(this.device.name),
secondary_text: URI.linkify(packet.body.text),
secondary_use_markup: true,
buttons: Gtk.ButtonsType.CLOSE,
});
dialog.message_area.get_children()[1].selectable = true;
dialog.set_keep_above(true);
dialog.connect('response', (dialog) => dialog.destroy());
dialog.show();
}
/**
* Open the file chooser dialog for selecting a file or inputing a URI.
*/
share() {
const dialog = new FileChooserDialog(this.device);
dialog.show();
}
/**
* Share local file path or URI
*
* @param {string} path - Local file path or URI
* @param {boolean} open - Whether the file should be opened after transfer
*/
async shareFile(path, open = false) {
try {
let file = null;
if (path.includes('://'))
file = Gio.File.new_for_uri(path);
else
file = Gio.File.new_for_path(path);
// Create the transfer
const transfer = this.device.createTransfer();
transfer.addFile({
type: 'kdeconnect.share.request',
body: {
filename: file.get_basename(),
open: open,
},
}, file);
// Notify that we're about to start the transfer
this.device.showNotification({
id: transfer.uuid,
title: _('Transferring File'),
// TRANSLATORS: eg. Sending 'book.pdf' to Google Pixel
body: _('Sending “%s” to %s').format(
file.get_basename(),
this.device.name
),
buttons: [{
label: _('Cancel'),
action: 'cancelTransfer',
parameter: new GLib.Variant('s', transfer.uuid),
}],
icon: new Gio.ThemedIcon({name: 'document-send-symbolic'}),
});
// We'll show a notification (success or failure)
let title, body, iconName;
try {
await transfer.start();
title = _('Transfer Successful');
// TRANSLATORS: eg. Sent "book.pdf" to Google Pixel
body = _('Sent “%s” to %s').format(
file.get_basename(),
this.device.name
);
iconName = 'document-send-symbolic';
} catch (e) {
debug(e, this.device.name);
title = _('Transfer Failed');
// TRANSLATORS: eg. Failed to send "book.pdf" to Google Pixel
body = _('Failed to send “%s” to %s').format(
file.get_basename(),
this.device.name
);
iconName = 'dialog-warning-symbolic';
}
this.device.hideNotification(transfer.uuid);
this.device.showNotification({
id: transfer.uuid,
title: title,
body: body,
icon: new Gio.ThemedIcon({name: iconName}),
});
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Share a string of text. Remote behaviour is undefined.
*
* @param {string} text - A string of unicode text
*/
shareText(text) {
this.device.sendPacket({
type: 'kdeconnect.share.request',
body: {text: text},
});
}
/**
* Share a URI. Generally the remote device opens it with the scheme default
*
* @param {string} uri - A URI to share
*/
shareUri(uri) {
if (GLib.uri_parse_scheme(uri) === 'file') {
this.shareFile(uri);
return;
}
this.device.sendPacket({
type: 'kdeconnect.share.request',
body: {url: uri},
});
}
});
/** A simple FileChooserDialog for sharing files */
const FileChooserDialog = GObject.registerClass({
GTypeName: 'GSConnectShareFileChooserDialog',
}, class FileChooserDialog extends Gtk.FileChooserDialog {
_init(device) {
super._init({
// TRANSLATORS: eg. Send files to Google Pixel
title: _('Send files to %s').format(device.name),
select_multiple: true,
extra_widget: new Gtk.CheckButton({
// TRANSLATORS: Mark the file to be opened once completed
label: _('Open when done'),
visible: true,
}),
use_preview_label: false,
});
this.device = device;
// Align checkbox with sidebar
const box = this.get_content_area().get_children()[0].get_children()[0];
const paned = box.get_children()[0];
paned.bind_property(
'position',
this.extra_widget,
'margin-left',
GObject.BindingFlags.SYNC_CREATE
);
// Preview Widget
this.preview_widget = new Gtk.Image();
this.preview_widget_active = false;
this.connect('update-preview', this._onUpdatePreview);
// URI entry
this._uriEntry = new Gtk.Entry({
placeholder_text: 'https://',
hexpand: true,
visible: true,
});
this._uriEntry.connect('activate', this._sendLink.bind(this));
// URI/File toggle
this._uriButton = new Gtk.ToggleButton({
image: new Gtk.Image({
icon_name: 'web-browser-symbolic',
pixel_size: 16,
}),
valign: Gtk.Align.CENTER,
// TRANSLATORS: eg. Send a link to Google Pixel
tooltip_text: _('Send a link to %s').format(device.name),
visible: true,
});
this._uriButton.connect('toggled', this._onUriButtonToggled.bind(this));
this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
const sendButton = this.add_button(_('Send'), Gtk.ResponseType.OK);
sendButton.connect('clicked', this._sendLink.bind(this));
this.get_header_bar().pack_end(this._uriButton);
this.set_default_response(Gtk.ResponseType.OK);
}
_onUpdatePreview(chooser) {
try {
const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
chooser.get_preview_filename(),
chooser.get_scale_factor() * 128,
-1
);
chooser.preview_widget.pixbuf = pixbuf;
chooser.preview_widget.visible = true;
chooser.preview_widget_active = true;
} catch (e) {
chooser.preview_widget.visible = false;
chooser.preview_widget_active = false;
}
}
_onUriButtonToggled(button) {
const header = this.get_header_bar();
// Show the URL entry
if (button.active) {
this.extra_widget.sensitive = false;
header.set_custom_title(this._uriEntry);
this.set_response_sensitive(Gtk.ResponseType.OK, true);
// Hide the URL entry
} else {
header.set_custom_title(null);
this.set_response_sensitive(
Gtk.ResponseType.OK,
this.get_uris().length > 1
);
this.extra_widget.sensitive = true;
}
}
_sendLink(widget) {
if (this._uriButton.active && this._uriEntry.text.length)
this.response(1);
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
for (const uri of this.get_uris()) {
const parameter = new GLib.Variant(
'(sb)',
[uri, this.extra_widget.active]
);
this.device.activate_action('shareFile', parameter);
}
} else if (response_id === 1) {
const parameter = new GLib.Variant('s', this._uriEntry.text);
this.device.activate_action('shareUri', parameter);
}
this.destroy();
}
});
export default SharePlugin;

View File

@@ -0,0 +1,536 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Plugin from '../plugin.js';
import LegacyMessagingDialog from '../ui/legacyMessaging.js';
import * as Messaging from '../ui/messaging.js';
import SmsURI from '../utils/uri.js';
export const Metadata = {
label: _('SMS'),
description: _('Send and read SMS of the paired device and be notified of new SMS'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SMS',
incomingCapabilities: [
'kdeconnect.sms.messages',
],
outgoingCapabilities: [
'kdeconnect.sms.request',
'kdeconnect.sms.request_conversation',
'kdeconnect.sms.request_conversations',
],
actions: {
// SMS Actions
sms: {
label: _('Messaging'),
icon_name: 'sms-symbolic',
parameter_type: null,
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
uriSms: {
label: _('New SMS (URI)'),
icon_name: 'sms-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
replySms: {
label: _('Reply SMS'),
icon_name: 'sms-symbolic',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
sendMessage: {
label: _('Send Message'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('(aa{sv})'),
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
sendSms: {
label: _('Send SMS'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('(ss)'),
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
shareSms: {
label: _('Share SMS'),
icon_name: 'sms-send',
parameter_type: new GLib.VariantType('s'),
incoming: [],
outgoing: ['kdeconnect.sms.request'],
},
},
};
/**
* SMS Message event type. Currently all events are TEXT_MESSAGE.
*
* TEXT_MESSAGE: Has a "body" field which contains pure, human-readable text
*/
export const MessageEventType = {
TEXT_MESSAGE: 0x1,
};
/**
* SMS Message status. READ/UNREAD match the 'read' field from the Android App
* message packet.
*
* UNREAD: A message not marked as read
* READ: A message marked as read
*/
export const MessageStatus = {
UNREAD: 0,
READ: 1,
};
/**
* SMS Message type, set from the 'type' field in the Android App
* message packet.
*
* See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
*
* ALL: all messages
* INBOX: Received messages
* SENT: Sent messages
* DRAFT: Message drafts
* OUTBOX: Outgoing messages
* FAILED: Failed outgoing messages
* QUEUED: Messages queued to send later
*/
export const MessageBox = {
ALL: 0,
INBOX: 1,
SENT: 2,
DRAFT: 3,
OUTBOX: 4,
FAILED: 5,
QUEUED: 6,
};
/**
* SMS Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sms
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SMSPlugin/
*/
const SMSPlugin = GObject.registerClass({
GTypeName: 'GSConnectSMSPlugin',
Properties: {
'threads': GObject.param_spec_variant(
'threads',
'Conversation List',
'A list of threads',
new GLib.VariantType('aa{sv}'),
null,
GObject.ParamFlags.READABLE
),
},
}, class SMSPlugin extends Plugin {
_init(device) {
super._init(device, 'sms');
this.cacheProperties(['_threads']);
}
get threads() {
if (this._threads === undefined)
this._threads = {};
return this._threads;
}
get window() {
if (this.settings.get_boolean('legacy-sms')) {
return new LegacyMessagingDialog({
device: this.device,
plugin: this,
});
}
if (this._window === undefined) {
this._window = new Messaging.Window({
application: Gio.Application.get_default(),
device: this.device,
plugin: this,
});
this._window.connect('destroy', () => {
this._window = undefined;
});
}
return this._window;
}
clearCache() {
this._threads = {};
this.notify('threads');
}
cacheLoaded() {
this.notify('threads');
}
connected() {
super.connected();
this._requestConversations();
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.sms.messages':
this._handleMessages(packet.body.messages);
break;
}
}
/**
* Handle a digest of threads.
*
* @param {Object[]} messages - A list of message objects
* @param {string[]} thread_ids - A list of thread IDs as strings
*/
_handleDigest(messages, thread_ids) {
// Prune threads
for (const thread_id of Object.keys(this.threads)) {
if (!thread_ids.includes(thread_id))
delete this.threads[thread_id];
}
// Request each new or newer thread
for (let i = 0, len = messages.length; i < len; i++) {
const message = messages[i];
const cache = this.threads[message.thread_id];
if (cache === undefined) {
this._requestConversation(message.thread_id);
continue;
}
// If this message is marked read, mark the rest as read
if (message.read === MessageStatus.READ) {
for (const msg of cache)
msg.read = MessageStatus.READ;
}
// If we don't have a thread for this message or it's newer
// than the last message in the cache, request the thread
if (!cache.length || cache[cache.length - 1].date < message.date)
this._requestConversation(message.thread_id);
}
this.notify('threads');
}
/**
* Handle a new single message
*
* @param {Object} message - A message object
*/
_handleMessage(message) {
let conversation = null;
// If the window is open, try and find an active conversation
if (this._window)
conversation = this._window.getConversationForMessage(message);
// If there's an active conversation, we should log the message now
if (conversation)
conversation.logNext(message);
}
/**
* Parse a conversation (thread of messages) and sort them
*
* @param {Object[]} thread - A list of sms message objects from a thread
*/
_handleThread(thread) {
// If there are no addresses this will cause major problems...
if (!thread[0].addresses || !thread[0].addresses[0])
return;
const thread_id = thread[0].thread_id;
const cache = this.threads[thread_id] || [];
// Handle each message
for (let i = 0, len = thread.length; i < len; i++) {
const message = thread[i];
// TODO: We only cache messages of a known MessageBox since we
// have no reliable way to determine its direction, let alone
// what to do with it.
if (message.type < 0 || message.type > 6)
continue;
// If the message exists, just update it
const cacheMessage = cache.find(m => m.date === message.date);
if (cacheMessage) {
Object.assign(cacheMessage, message);
} else {
cache.push(message);
this._handleMessage(message);
}
}
// Sort the thread by ascending date and notify
this.threads[thread_id] = cache.sort((a, b) => a.date - b.date);
this.notify('threads');
}
/**
* Handle a response to telephony.request_conversation(s)
*
* @param {Object[]} messages - A list of sms message objects
*/
_handleMessages(messages) {
try {
// If messages is empty there's nothing to do...
if (messages.length === 0)
return;
const thread_ids = [];
// Perform some modification of the messages
for (let i = 0, len = messages.length; i < len; i++) {
const message = messages[i];
// COERCION: thread_id's to strings
message.thread_id = `${message.thread_id}`;
thread_ids.push(message.thread_id);
// TODO: Remove bogus `insert-address-token` entries
let a = message.addresses.length;
while (a--) {
if (message.addresses[a].address === undefined ||
message.addresses[a].address === 'insert-address-token')
message.addresses.splice(a, 1);
}
}
// If there's multiple thread_id's it's a summary of threads
if (thread_ids.some(id => id !== thread_ids[0]))
this._handleDigest(messages, thread_ids);
// Otherwise this is single thread or new message
else
this._handleThread(messages);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Request a list of messages from a single thread.
*
* @param {number} thread_id - The id of the thread to request
*/
_requestConversation(thread_id) {
this.device.sendPacket({
type: 'kdeconnect.sms.request_conversation',
body: {
threadID: thread_id,
},
});
}
/**
* Request a list of the last message in each unarchived thread.
*/
_requestConversations() {
this.device.sendPacket({
type: 'kdeconnect.sms.request_conversations',
});
}
/**
* A notification action for replying to SMS messages (or missed calls).
*
* @param {string} hint - Could be either a contact name or phone number
*/
replySms(hint) {
this.window.present();
// FIXME: causes problems now that non-numeric addresses are allowed
// this.window.address = hint.toPhoneNumber();
}
/**
* Send an SMS message
*
* @param {string} phoneNumber - The phone number to send the message to
* @param {string} messageBody - The message to send
*/
sendSms(phoneNumber, messageBody) {
this.device.sendPacket({
type: 'kdeconnect.sms.request',
body: {
sendSms: true,
phoneNumber: phoneNumber,
messageBody: messageBody,
},
});
}
/**
* Send a message
*
* @param {Object[]} addresses - A list of address objects
* @param {string} messageBody - The message text
* @param {number} [event] - An event bitmask
* @param {boolean} [forceSms] - Whether to force SMS
* @param {number} [subId] - The SIM card to use
*/
sendMessage(addresses, messageBody, event = 1, forceSms = false, subId = undefined) {
// TODO: waiting on support in kdeconnect-android
// if (this._version === 1) {
this.device.sendPacket({
type: 'kdeconnect.sms.request',
body: {
sendSms: true,
phoneNumber: addresses[0].address,
messageBody: messageBody,
},
});
// } else if (this._version === 2) {
// this.device.sendPacket({
// type: 'kdeconnect.sms.request',
// body: {
// version: 2,
// addresses: addresses,
// messageBody: messageBody,
// forceSms: forceSms,
// sub_id: subId
// }
// });
// }
}
/**
* Share a text content by SMS message. This is used by the WebExtension to
* share URLs from the browser, but could be used to initiate sharing of any
* text content.
*
* @param {string} url - The link to be shared
*/
shareSms(url) {
// Legacy Mode
if (this.settings.get_boolean('legacy-sms')) {
const window = this.window;
window.present();
window.setMessage(url);
// If there are active threads, show the chooser dialog
} else if (Object.values(this.threads).length > 0) {
const window = new Messaging.ConversationChooser({
application: Gio.Application.get_default(),
device: this.device,
message: url,
plugin: this,
});
window.present();
// Otherwise show the window and wait for a contact to be chosen
} else {
this.window.present();
this.window.setMessage(url, true);
}
}
/**
* Open and present the messaging window
*/
sms() {
this.window.present();
}
/**
* This is the sms: URI scheme handler
*
* @param {string} uri - The URI the handle (sms:|sms://|sms:///)
*/
uriSms(uri) {
try {
uri = new SmsURI(uri);
// Lookup contacts
const addresses = uri.recipients.map(number => {
return {address: number.toPhoneNumber()};
});
const contacts = this.device.contacts.lookupAddresses(addresses);
// Present the window and show the conversation
const window = this.window;
window.present();
window.setContacts(contacts);
// Set the outgoing message if the uri has a body variable
if (uri.body)
window.setMessage(uri.body);
} catch (e) {
debug(e, `${this.device.name}: "${uri}"`);
}
}
_threadHasAddress(thread, addressObj) {
const number = addressObj.address.toPhoneNumber();
for (const taddressObj of thread[0].addresses) {
const tnumber = taddressObj.address.toPhoneNumber();
if (number.endsWith(tnumber) || tnumber.endsWith(number))
return true;
}
return false;
}
/**
* Try to find a thread_id in @smsPlugin for @addresses.
*
* @param {Object[]} addresses - a list of address objects
* @return {string|null} a thread ID
*/
getThreadIdForAddresses(addresses = []) {
const threads = Object.values(this.threads);
for (const thread of threads) {
if (addresses.length !== thread[0].addresses.length)
continue;
if (addresses.every(addressObj => this._threadHasAddress(thread, addressObj)))
return thread[0].thread_id;
}
return null;
}
destroy() {
if (this._window !== undefined)
this._window.destroy();
super.destroy();
}
});
export default SMSPlugin;

View File

@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Config from '../../config.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('System Volume'),
description: _('Enable the paired device to control the system volume'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SystemVolume',
incomingCapabilities: ['kdeconnect.systemvolume.request'],
outgoingCapabilities: ['kdeconnect.systemvolume'],
actions: {},
};
/**
* SystemVolume Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/systemvolume
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SystemvolumePlugin/
*/
const SystemVolumePlugin = GObject.registerClass({
GTypeName: 'GSConnectSystemVolumePlugin',
}, class SystemVolumePlugin extends Plugin {
_init(device) {
super._init(device, 'systemvolume');
// Cache stream properties
this._cache = new WeakMap();
// Connect to the mixer
try {
this._mixer = Components.acquire('pulseaudio');
this._streamChangedId = this._mixer.connect(
'stream-changed',
this._sendSink.bind(this)
);
this._outputAddedId = this._mixer.connect(
'output-added',
this._sendSinkList.bind(this)
);
this._outputRemovedId = this._mixer.connect(
'output-removed',
this._sendSinkList.bind(this)
);
// Modify the error to redirect to the wiki
} catch (e) {
e.name = _('PulseAudio not found');
e.url = `${Config.PACKAGE_URL}/wiki/Error#pulseaudio-not-found`;
throw e;
}
}
handlePacket(packet) {
switch (true) {
case packet.body.hasOwnProperty('requestSinks'):
this._sendSinkList();
break;
case packet.body.hasOwnProperty('name'):
this._changeSink(packet);
break;
}
}
connected() {
super.connected();
this._sendSinkList();
}
/**
* Handle a request to change an output
*
* @param {Core.Packet} packet - a `kdeconnect.systemvolume.request`
*/
_changeSink(packet) {
let stream;
for (const sink of this._mixer.get_sinks()) {
if (sink.name === packet.body.name) {
stream = sink;
break;
}
}
// No sink with the given name
if (stream === undefined) {
this._sendSinkList();
return;
}
// Get a cache and store volume and mute states if changed
const cache = this._cache.get(stream) || {};
if (packet.body.hasOwnProperty('muted')) {
cache.muted = packet.body.muted;
this._cache.set(stream, cache);
stream.change_is_muted(packet.body.muted);
}
if (packet.body.hasOwnProperty('volume')) {
cache.volume = packet.body.volume;
this._cache.set(stream, cache);
stream.volume = packet.body.volume;
stream.push_volume();
}
}
/**
* Update the cache for @stream
*
* @param {Gvc.MixerStream} stream - The stream to cache
* @return {Object} The updated cache object
*/
_updateCache(stream) {
const state = {
name: stream.name,
description: stream.display_name,
muted: stream.is_muted,
volume: stream.volume,
maxVolume: this._mixer.get_vol_max_norm(),
};
this._cache.set(stream, state);
return state;
}
/**
* Send the state of a local sink
*
* @param {Gvc.MixerControl} mixer - The mixer that owns the stream
* @param {number} id - The Id of the stream that changed
*/
_sendSink(mixer, id) {
// Avoid starving the packet channel when fading
if (this._mixer.fading)
return;
// Check the cache
const stream = this._mixer.lookup_stream_id(id);
const cache = this._cache.get(stream) || {};
// If the port has changed we have to send the whole list to update the
// display name
if (!cache.display_name || cache.display_name !== stream.display_name) {
this._sendSinkList();
return;
}
// If only volume and/or mute are set, send a single update
if (cache.volume !== stream.volume || cache.muted !== stream.is_muted) {
// Update the cache
const state = this._updateCache(stream);
// Send the stream update
this.device.sendPacket({
type: 'kdeconnect.systemvolume',
body: state,
});
}
}
/**
* Send a list of local sinks
*/
_sendSinkList() {
const sinkList = this._mixer.get_sinks().map(sink => {
return this._updateCache(sink);
});
// Send the sinkList
this.device.sendPacket({
type: 'kdeconnect.systemvolume',
body: {
sinkList: sinkList,
},
});
}
destroy() {
if (this._mixer !== undefined) {
this._mixer.disconnect(this._streamChangedId);
this._mixer.disconnect(this._outputAddedId);
this._mixer.disconnect(this._outputRemovedId);
this._mixer = Components.release('pulseaudio');
}
super.destroy();
}
});
export default SystemVolumePlugin;

View File

@@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as Components from '../components/index.js';
import Plugin from '../plugin.js';
export const Metadata = {
label: _('Telephony'),
description: _('Be notified about calls and adjust system volume during ringing/ongoing calls'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony',
incomingCapabilities: [
'kdeconnect.telephony',
],
outgoingCapabilities: [
'kdeconnect.telephony.request',
'kdeconnect.telephony.request_mute',
],
actions: {
muteCall: {
// TRANSLATORS: Silence the actively ringing call
label: _('Mute Call'),
icon_name: 'audio-volume-muted-symbolic',
parameter_type: null,
incoming: ['kdeconnect.telephony'],
outgoing: ['kdeconnect.telephony.request_mute'],
},
},
};
/**
* Telephony Plugin
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/telephony
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/TelephonyPlugin
*/
const TelephonyPlugin = GObject.registerClass({
GTypeName: 'GSConnectTelephonyPlugin',
}, class TelephonyPlugin extends Plugin {
_init(device) {
super._init(device, 'telephony');
// Neither of these are crucial for the plugin to work
this._mpris = Components.acquire('mpris');
this._mixer = Components.acquire('pulseaudio');
}
handlePacket(packet) {
switch (packet.type) {
case 'kdeconnect.telephony':
this._handleEvent(packet);
break;
}
}
/**
* Change volume, microphone and media player state in response to an
* incoming or answered call.
*
* @param {string} eventType - 'ringing' or 'talking'
*/
_setMediaState(eventType) {
// Mixer Volume
if (this._mixer !== undefined) {
switch (this.settings.get_string(`${eventType}-volume`)) {
case 'restore':
this._mixer.restore();
break;
case 'lower':
this._mixer.lowerVolume();
break;
case 'mute':
this._mixer.muteVolume();
break;
}
if (eventType === 'talking' && this.settings.get_boolean('talking-microphone'))
this._mixer.muteMicrophone();
}
// Media Playback
if (this._mpris && this.settings.get_boolean(`${eventType}-pause`))
this._mpris.pauseAll();
}
/**
* Restore volume, microphone and media player state (if changed), making
* sure to unpause before raising volume.
*
* TODO: there's a possibility we might revert a media/mixer state set for
* another device.
*/
_restoreMediaState() {
// Media Playback
if (this._mpris)
this._mpris.unpauseAll();
// Mixer Volume
if (this._mixer)
this._mixer.restore();
}
/**
* Load a Gdk.Pixbuf from base64 encoded data
*
* @param {string} data - Base64 encoded JPEG data
* @return {Gdk.Pixbuf|null} A contact photo
*/
_getThumbnailPixbuf(data) {
const loader = new GdkPixbuf.PixbufLoader();
try {
data = GLib.base64_decode(data);
loader.write(data);
loader.close();
} catch (e) {
debug(e, this.device.name);
}
return loader.get_pixbuf();
}
/**
* Handle a telephony event (ringing, talking), showing or hiding a
* notification and possibly adjusting the media/mixer state.
*
* @param {Core.Packet} packet - A `kdeconnect.telephony`
*/
_handleEvent(packet) {
// Only handle 'ringing' or 'talking' events; leave the notification
// plugin to handle 'missedCall' since they're often repliable
if (!['ringing', 'talking'].includes(packet.body.event))
return;
// This is the end of a telephony event
if (packet.body.isCancel)
this._cancelEvent(packet);
else
this._notifyEvent(packet);
}
_cancelEvent(packet) {
// Ensure we have a sender
// TRANSLATORS: No name or phone number
let sender = _('Unknown Contact');
if (packet.body.contactName)
sender = packet.body.contactName;
else if (packet.body.phoneNumber)
sender = packet.body.phoneNumber;
this.device.hideNotification(`${packet.body.event}|${sender}`);
this._restoreMediaState();
}
_notifyEvent(packet) {
let body;
let buttons = [];
let icon = null;
let priority = Gio.NotificationPriority.NORMAL;
// Ensure we have a sender
// TRANSLATORS: No name or phone number
let sender = _('Unknown Contact');
if (packet.body.contactName)
sender = packet.body.contactName;
else if (packet.body.phoneNumber)
sender = packet.body.phoneNumber;
// If there's a photo, use it as the notification icon
if (packet.body.phoneThumbnail)
icon = this._getThumbnailPixbuf(packet.body.phoneThumbnail);
if (icon === null)
icon = new Gio.ThemedIcon({name: 'call-start-symbolic'});
// Notify based based on the event type
if (packet.body.event === 'ringing') {
this._setMediaState('ringing');
// TRANSLATORS: The phone is ringing
body = _('Incoming call');
buttons = [{
action: 'muteCall',
// TRANSLATORS: Silence the actively ringing call
label: _('Mute'),
parameter: null,
}];
priority = Gio.NotificationPriority.URGENT;
}
if (packet.body.event === 'talking') {
this.device.hideNotification(`ringing|${sender}`);
this._setMediaState('talking');
// TRANSLATORS: A phone call is active
body = _('Ongoing call');
}
this.device.showNotification({
id: `${packet.body.event}|${sender}`,
title: sender,
body: body,
icon: icon,
priority: priority,
buttons: buttons,
});
}
/**
* Silence an incoming call and restore the previous mixer/media state, if
* applicable.
*/
muteCall() {
this.device.sendPacket({
type: 'kdeconnect.telephony.request_mute',
body: {},
});
this._restoreMediaState();
}
destroy() {
if (this._mixer !== undefined)
this._mixer = Components.release('pulseaudio');
if (this._mpris !== undefined)
this._mpris = Components.release('mpris');
super.destroy();
}
});
export default TelephonyPlugin;

View File

@@ -0,0 +1,642 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import GdkPixbuf from 'gi://GdkPixbuf';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import system from 'system';
/**
* Return a random color
*
* @param {*} [salt] - If not %null, will be used as salt for generating a color
* @param {number} alpha - A value in the [0...1] range for the alpha channel
* @return {Gdk.RGBA} A new Gdk.RGBA object generated from the input
*/
function randomRGBA(salt = null, alpha = 1.0) {
let red, green, blue;
if (salt !== null) {
const hash = new GLib.Variant('s', `${salt}`).hash();
red = ((hash & 0xFF0000) >> 16) / 255;
green = ((hash & 0x00FF00) >> 8) / 255;
blue = (hash & 0x0000FF) / 255;
} else {
red = Math.random();
green = Math.random();
blue = Math.random();
}
return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
}
/**
* Get the relative luminance of a RGB set
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
*
* @param {Gdk.RGBA} rgba - A GdkRGBA object
* @return {number} The relative luminance of the color
*/
function relativeLuminance(rgba) {
const {red, green, blue} = rgba;
const R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
const G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
const B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
/**
* Get a GdkRGBA contrasted for the input
* See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Gdk.RGBA} rgba - A GdkRGBA object for the background color
* @return {Gdk.RGBA} A GdkRGBA object for the foreground color
*/
function getFgRGBA(rgba) {
const bgLuminance = relativeLuminance(rgba);
const lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
const darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);
const value = (darkContrast > lightContrast) ? 0.06 : 0.94;
return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
}
/**
* Get a GdkPixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
* sends. This function is synchronous.
*
* @param {string} path - A local file path
* @param {number} size - Size in pixels
* @param {scale} [scale] - Scale factor for the size
* @return {Gdk.Pixbuf} A pixbuf
*/
function getPixbufForPath(path, size, scale = 1.0) {
let data, loader;
// Catch missing avatar files
try {
data = GLib.file_get_contents(path)[1];
} catch (e) {
debug(e, path);
return undefined;
}
// Consider errors from partially corrupt JPEGs to be warnings
try {
loader = new GdkPixbuf.PixbufLoader();
loader.write(data);
loader.close();
} catch (e) {
debug(e, path);
}
const pixbuf = loader.get_pixbuf();
// Scale to monitor
size = Math.floor(size * scale);
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
}
function getPixbufForIcon(name, size, scale, bgColor) {
const color = getFgRGBA(bgColor);
const theme = Gtk.IconTheme.get_default();
const info = theme.lookup_icon_for_scale(
name,
size,
scale,
Gtk.IconLookupFlags.FORCE_SYMBOLIC
);
return info.load_symbolic(color, null, null, null)[0];
}
/**
* Return a localized string for a phone number type
* See: http://www.ietf.org/rfc/rfc2426.txt
*
* @param {string} type - An RFC2426 phone number type
* @return {string} A localized string like 'Mobile'
*/
function getNumberTypeLabel(type) {
if (type.includes('fax'))
// TRANSLATORS: A fax number
return _('Fax');
if (type.includes('work'))
// TRANSLATORS: A work or office phone number
return _('Work');
if (type.includes('cell'))
// TRANSLATORS: A mobile or cellular phone number
return _('Mobile');
if (type.includes('home'))
// TRANSLATORS: A home phone number
return _('Home');
// TRANSLATORS: All other phone number types
return _('Other');
}
/**
* Get a display number from @contact for @address.
*
* @param {Object} contact - A contact object
* @param {string} address - A phone number
* @return {string} A (possibly) better display number for the address
*/
export function getDisplayNumber(contact, address) {
const number = address.toPhoneNumber();
for (const contactNumber of contact.numbers) {
const cnumber = contactNumber.value.toPhoneNumber();
if (number.endsWith(cnumber) || cnumber.endsWith(number))
return GLib.markup_escape_text(contactNumber.value, -1);
}
return GLib.markup_escape_text(address, -1);
}
/**
* Contact Avatar
*/
const AvatarCache = new WeakMap();
export const Avatar = GObject.registerClass({
GTypeName: 'GSConnectContactAvatar',
}, class ContactAvatar extends Gtk.DrawingArea {
_init(contact = null) {
super._init({
height_request: 32,
width_request: 32,
valign: Gtk.Align.CENTER,
visible: true,
});
this.contact = contact;
}
get rgba() {
if (this._rgba === undefined) {
if (this.contact)
this._rgba = randomRGBA(this.contact.name);
else
this._rgba = randomRGBA(GLib.uuid_string_random());
}
return this._rgba;
}
get contact() {
if (this._contact === undefined)
this._contact = null;
return this._contact;
}
set contact(contact) {
if (this.contact === contact)
return;
this._contact = contact;
this._surface = undefined;
this._rgba = undefined;
this._offset = 0;
}
_loadSurface() {
// Get the monitor scale
const display = Gdk.Display.get_default();
const monitor = display.get_monitor_at_window(this.get_window());
const scale = monitor.get_scale_factor();
// If there's a contact with an avatar, try to load it
if (this.contact && this.contact.avatar) {
// Check the cache
this._surface = AvatarCache.get(this.contact);
// Try loading the pixbuf
if (!this._surface) {
const pixbuf = getPixbufForPath(
this.contact.avatar,
this.width_request,
scale
);
if (pixbuf) {
this._surface = Gdk.cairo_surface_create_from_pixbuf(
pixbuf,
0,
this.get_window()
);
AvatarCache.set(this.contact, this._surface);
}
}
}
// If we still don't have a surface, load a fallback
if (!this._surface) {
let iconName;
// If we were given a contact, it's direct message otherwise group
if (this.contact)
iconName = 'avatar-default-symbolic';
else
iconName = 'group-avatar-symbolic';
// Center the icon
this._offset = (this.width_request - 24) / 2;
// Load the fallback
const pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);
this._surface = Gdk.cairo_surface_create_from_pixbuf(
pixbuf,
0,
this.get_window()
);
}
}
vfunc_draw(cr) {
if (!this._surface)
this._loadSurface();
// Clip to a circle
const rad = this.width_request / 2;
cr.arc(rad, rad, rad, 0, 2 * Math.PI);
cr.clipPreserve();
// Fill the background if the the surface is offset
if (this._offset > 0) {
Gdk.cairo_set_source_rgba(cr, this.rgba);
cr.fill();
}
// Draw the avatar/icon
cr.setSourceSurface(this._surface, this._offset, this._offset);
cr.paint();
cr.$dispose();
return Gdk.EVENT_PROPAGATE;
}
});
/**
* A row for a contact address (usually a phone number).
*/
const AddressRow = GObject.registerClass({
GTypeName: 'GSConnectContactsAddressRow',
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contacts-address-row.ui',
Children: ['avatar', 'name-label', 'address-label', 'type-label'],
}, class AddressRow extends Gtk.ListBoxRow {
_init(contact, index = 0) {
super._init();
this._index = index;
this._number = contact.numbers[index];
this.contact = contact;
}
get contact() {
if (this._contact === undefined)
this._contact = null;
return this._contact;
}
set contact(contact) {
if (this.contact === contact)
return;
this._contact = contact;
if (this._index === 0) {
this.avatar.contact = contact;
this.avatar.visible = true;
this.name_label.label = GLib.markup_escape_text(contact.name, -1);
this.name_label.visible = true;
this.address_label.margin_start = 0;
this.address_label.margin_end = 0;
} else {
this.avatar.visible = false;
this.name_label.visible = false;
// TODO: rtl inverts margin-start so the number don't align
this.address_label.margin_start = 38;
this.address_label.margin_end = 38;
}
this.address_label.label = GLib.markup_escape_text(this.number.value, -1);
if (this.number.type !== undefined)
this.type_label.label = getNumberTypeLabel(this.number.type);
}
get number() {
if (this._number === undefined)
return {value: 'unknown', type: 'unknown'};
return this._number;
}
});
/**
* A widget for selecting contact addresses (usually phone numbers)
*/
export const ContactChooser = GObject.registerClass({
GTypeName: 'GSConnectContactChooser',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'store': GObject.ParamSpec.object(
'store',
'Store',
'The contacts store',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
GObject.Object
),
},
Signals: {
'number-selected': {
flags: GObject.SignalFlags.RUN_FIRST,
param_types: [GObject.TYPE_STRING],
},
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
Children: ['entry', 'list', 'scrolled'],
}, class ContactChooser extends Gtk.Grid {
_init(params) {
super._init(params);
// Setup the contact list
this.list._entry = this.entry.text;
this.list.set_filter_func(this._filter);
this.list.set_sort_func(this._sort);
// Make sure we're using the correct contacts store
this.device.bind_property(
'contacts',
this,
'store',
GObject.BindingFlags.SYNC_CREATE
);
// Cleanup on ::destroy
this.connect('destroy', this._onDestroy);
}
get store() {
if (this._store === undefined)
this._store = null;
return this._store;
}
set store(store) {
if (this.store === store)
return;
// Unbind the old store
if (this._store) {
// Disconnect from the store
this._store.disconnect(this._contactAddedId);
this._store.disconnect(this._contactRemovedId);
this._store.disconnect(this._contactChangedId);
// Clear the contact list
const rows = this.list.get_children();
for (let i = 0, len = rows.length; i < len; i++) {
rows[i].destroy();
// HACK: temporary mitigator for mysterious GtkListBox leak
system.gc();
}
}
// Set the store
this._store = store;
// Bind the new store
if (this._store) {
// Connect to the new store
this._contactAddedId = store.connect(
'contact-added',
this._onContactAdded.bind(this)
);
this._contactRemovedId = store.connect(
'contact-removed',
this._onContactRemoved.bind(this)
);
this._contactChangedId = store.connect(
'contact-changed',
this._onContactChanged.bind(this)
);
// Populate the list
this._populate();
}
}
/*
* ContactStore Callbacks
*/
_onContactAdded(store, id) {
const contact = this.store.get_contact(id);
this._addContact(contact);
}
_onContactRemoved(store, id) {
const rows = this.list.get_children();
for (let i = 0, len = rows.length; i < len; i++) {
const row = rows[i];
if (row.contact.id === id) {
row.destroy();
// HACK: temporary mitigator for mysterious GtkListBox leak
system.gc();
}
}
}
_onContactChanged(store, id) {
this._onContactRemoved(store, id);
this._onContactAdded(store, id);
}
_onDestroy(chooser) {
chooser.store = null;
}
_onSearchChanged(entry) {
this.list._entry = entry.text;
let dynamic = this.list.get_row_at_index(0);
// If the entry contains string with 2 or more digits...
if (entry.text.replace(/\D/g, '').length >= 2) {
// ...ensure we have a dynamic contact for it
if (!dynamic || !dynamic.__tmp) {
dynamic = new AddressRow({
// TRANSLATORS: A phone number (eg. "Send to 555-5555")
name: _('Send to %s').format(entry.text),
numbers: [{type: 'unknown', value: entry.text}],
});
dynamic.__tmp = true;
this.list.add(dynamic);
// ...or if we already do, then update it
} else {
const address = entry.text;
// Update contact object
dynamic.contact.name = address;
dynamic.contact.numbers[0].value = address;
// Update UI
dynamic.name_label.label = _('Send to %s').format(address);
dynamic.address_label.label = address;
}
// ...otherwise remove any dynamic contact that's been created
} else if (dynamic && dynamic.__tmp) {
dynamic.destroy();
}
this.list.invalidate_filter();
this.list.invalidate_sort();
}
// GtkListBox::row-activated
_onNumberSelected(box, row) {
if (row === null)
return;
// Emit the number
const address = row.number.value;
this.emit('number-selected', address);
// Reset the contact list
this.entry.text = '';
this.list.select_row(null);
this.scrolled.vadjustment.value = 0;
}
_filter(row) {
// Dynamic contact always shown
if (row.__tmp)
return true;
const query = row.get_parent()._entry;
// Show contact if text is substring of name
const queryName = query.toLocaleLowerCase();
if (row.contact.name.toLocaleLowerCase().includes(queryName))
return true;
// Show contact if text is substring of number
const queryNumber = query.toPhoneNumber();
if (queryNumber.length) {
for (const number of row.contact.numbers) {
if (number.value.toPhoneNumber().includes(queryNumber))
return true;
}
// Query is effectively empty
} else if (/^0+/.test(query)) {
return true;
}
return false;
}
_sort(row1, row2) {
if (row1.__tmp)
return -1;
if (row2.__tmp)
return 1;
return row1.contact.name.localeCompare(row2.contact.name);
}
_populate() {
// Add each contact
const contacts = this.store.contacts;
for (let i = 0, len = contacts.length; i < len; i++)
this._addContact(contacts[i]);
}
_addContactNumber(contact, index) {
const row = new AddressRow(contact, index);
this.list.add(row);
return row;
}
_addContact(contact) {
try {
// HACK: fix missing contact names
if (contact.name === undefined)
contact.name = _('Unknown Contact');
if (contact.numbers.length === 1)
return this._addContactNumber(contact, 0);
for (let i = 0, len = contact.numbers.length; i < len; i++)
this._addContactNumber(contact, i);
} catch (e) {
logError(e);
}
}
/**
* Get a dictionary of number-contact pairs for each selected phone number.
*
* @return {Object[]} A dictionary of contacts
*/
getSelected() {
try {
const selected = {};
for (const row of this.list.get_selected_rows())
selected[row.number.value] = row.contact;
return selected;
} catch (e) {
logError(e);
return {};
}
}
});

View File

@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as Contacts from '../ui/contacts.js';
import * as Messaging from '../ui/messaging.js';
import * as URI from '../utils/uri.js';
import '../utils/ui.js';
const Dialog = GObject.registerClass({
GTypeName: 'GSConnectLegacyMessagingDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The plugin providing messages',
GObject.ParamFlags.READWRITE,
GObject.Object
),
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/legacy-messaging-dialog.ui',
Children: [
'infobar', 'stack',
'message-box', 'message-avatar', 'message-label', 'entry',
],
}, class Dialog extends Gtk.Dialog {
_init(params) {
super._init({
application: Gio.Application.get_default(),
device: params.device,
plugin: params.plugin,
use_header_bar: true,
});
this.set_response_sensitive(Gtk.ResponseType.OK, false);
// Dup some functions
this.headerbar = this.get_titlebar();
this._setHeaderBar = Messaging.Window.prototype._setHeaderBar;
// Info bar
this.device.bind_property(
'connected',
this.infobar,
'reveal-child',
GObject.BindingFlags.INVERT_BOOLEAN
);
// Message Entry/Send Button
this.device.bind_property(
'connected',
this.entry,
'sensitive',
GObject.BindingFlags.DEFAULT
);
this._connectedId = this.device.connect(
'notify::connected',
this._onStateChanged.bind(this)
);
this._entryChangedId = this.entry.buffer.connect(
'changed',
this._onStateChanged.bind(this)
);
// Set the message if given
if (params.message) {
this.message = params.message;
this.addresses = params.message.addresses;
this.message_avatar.contact = this.device.contacts.query({
number: this.addresses[0].address,
});
this.message_label.label = URI.linkify(this.message.body);
this.message_box.visible = true;
// Otherwise set the address(es) if we were passed those
} else if (params.addresses) {
this.addresses = params.addresses;
}
// Load the contact list if we weren't supplied with an address
if (this.addresses.length === 0) {
this.contact_chooser = new Contacts.ContactChooser({
device: this.device,
});
this.stack.add_named(this.contact_chooser, 'contact-chooser');
this.stack.child_set_property(this.contact_chooser, 'position', 0);
this._numberSelectedId = this.contact_chooser.connect(
'number-selected',
this._onNumberSelected.bind(this)
);
this.stack.visible_child_name = 'contact-chooser';
}
this.restoreGeometry('legacy-messaging-dialog');
this.connect('destroy', this._onDestroy);
}
_onDestroy(dialog) {
if (dialog._numberSelectedId !== undefined) {
dialog.contact_chooser.disconnect(dialog._numberSelectedId);
dialog.contact_chooser.destroy();
}
dialog.entry.buffer.disconnect(dialog._entryChangedId);
dialog.device.disconnect(dialog._connectedId);
}
vfunc_delete_event() {
this.saveGeometry();
return false;
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
// Refuse to send empty or whitespace only texts
if (!this.entry.buffer.text.trim())
return;
this.plugin.sendMessage(
this.addresses,
this.entry.buffer.text,
1,
true
);
}
this.destroy();
}
get addresses() {
if (this._addresses === undefined)
this._addresses = [];
return this._addresses;
}
set addresses(addresses = []) {
this._addresses = addresses;
// Set the headerbar
this._setHeaderBar(this._addresses);
// Show the message editor
this.stack.visible_child_name = 'message-editor';
this._onStateChanged();
}
get device() {
if (this._device === undefined)
this._device = null;
return this._device;
}
set device(device) {
this._device = device;
}
get plugin() {
if (this._plugin === undefined)
this._plugin = null;
return this._plugin;
}
set plugin(plugin) {
this._plugin = plugin;
}
_onActivateLink(label, uri) {
Gtk.show_uri_on_window(
this.get_toplevel(),
uri.includes('://') ? uri : `https://${uri}`,
Gtk.get_current_event_time()
);
return true;
}
_onNumberSelected(chooser, number) {
const contacts = chooser.getSelected();
this.addresses = Object.keys(contacts).map(address => {
return {address: address};
});
}
_onStateChanged() {
if (this.device.connected &&
this.entry.buffer.text.trim() &&
this.stack.visible_child_name === 'message-editor')
this.set_response_sensitive(Gtk.ResponseType.OK, true);
else
this.set_response_sensitive(Gtk.ResponseType.OK, false);
}
/**
* Set the contents of the message entry
*
* @param {string} text - The message to place in the entry
*/
setMessage(text) {
this.entry.buffer.text = text;
}
});
export default Dialog;

View File

@@ -0,0 +1,460 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import Gdk from 'gi://Gdk';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
/**
* A map of Gdk to "KDE Connect" keyvals
*/
const ReverseKeyMap = new Map([
[Gdk.KEY_BackSpace, 1],
[Gdk.KEY_Tab, 2],
[Gdk.KEY_Linefeed, 3],
[Gdk.KEY_Left, 4],
[Gdk.KEY_Up, 5],
[Gdk.KEY_Right, 6],
[Gdk.KEY_Down, 7],
[Gdk.KEY_Page_Up, 8],
[Gdk.KEY_Page_Down, 9],
[Gdk.KEY_Home, 10],
[Gdk.KEY_End, 11],
[Gdk.KEY_Return, 12],
[Gdk.KEY_Delete, 13],
[Gdk.KEY_Escape, 14],
[Gdk.KEY_Sys_Req, 15],
[Gdk.KEY_Scroll_Lock, 16],
[Gdk.KEY_F1, 21],
[Gdk.KEY_F2, 22],
[Gdk.KEY_F3, 23],
[Gdk.KEY_F4, 24],
[Gdk.KEY_F5, 25],
[Gdk.KEY_F6, 26],
[Gdk.KEY_F7, 27],
[Gdk.KEY_F8, 28],
[Gdk.KEY_F9, 29],
[Gdk.KEY_F10, 30],
[Gdk.KEY_F11, 31],
[Gdk.KEY_F12, 32],
]);
/*
* A list of keyvals we consider modifiers
*/
const MOD_KEYS = [
Gdk.KEY_Alt_L,
Gdk.KEY_Alt_R,
Gdk.KEY_Caps_Lock,
Gdk.KEY_Control_L,
Gdk.KEY_Control_R,
Gdk.KEY_Meta_L,
Gdk.KEY_Meta_R,
Gdk.KEY_Num_Lock,
Gdk.KEY_Shift_L,
Gdk.KEY_Shift_R,
Gdk.KEY_Super_L,
Gdk.KEY_Super_R,
];
/*
* Some convenience functions for checking keyvals for modifiers
*/
const isAlt = (key) => [Gdk.KEY_Alt_L, Gdk.KEY_Alt_R].includes(key);
const isCtrl = (key) => [Gdk.KEY_Control_L, Gdk.KEY_Control_R].includes(key);
const isShift = (key) => [Gdk.KEY_Shift_L, Gdk.KEY_Shift_R].includes(key);
const isSuper = (key) => [Gdk.KEY_Super_L, Gdk.KEY_Super_R].includes(key);
export const InputDialog = GObject.registerClass({
GTypeName: 'GSConnectMousepadInputDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The mousepad plugin associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/mousepad-input-dialog.ui',
Children: [
'infobar', 'infobar-label',
'touchpad-eventbox', 'mouse-left-button', 'mouse-middle-button', 'mouse-right-button',
'touchpad-drag', 'touchpad-long-press',
'shift-label', 'ctrl-label', 'alt-label', 'super-label', 'entry',
],
}, class InputDialog extends Gtk.Dialog {
_init(params) {
super._init(Object.assign({
use_header_bar: true,
}, params));
const headerbar = this.get_titlebar();
headerbar.title = _('Remote Input');
headerbar.subtitle = this.device.name;
// Main Box
const content = this.get_content_area();
content.border_width = 0;
// TRANSLATORS: Displayed when the remote keyboard is not ready to accept input
this.infobar_label.label = _('Remote keyboard on %s is not active').format(this.device.name);
// Text Input
this.entry.buffer.connect(
'insert-text',
this._onInsertText.bind(this)
);
this.infobar.connect('notify::reveal-child', this._onState.bind(this));
this.plugin.bind_property('state', this.infobar, 'reveal-child', 6);
// Mouse Pad
this._resetTouchpadMotion();
this.touchpad_motion_timeout_id = 0;
this.touchpad_holding = false;
// Scroll Input
this.add_events(Gdk.EventMask.SCROLL_MASK);
this.show_all();
}
vfunc_delete_event(event) {
this._ungrab();
return this.hide_on_delete();
}
vfunc_grab_broken_event(event) {
if (event.keyboard)
this._ungrab();
return false;
}
vfunc_key_release_event(event) {
if (!this.plugin.state)
debug('ignoring remote keyboard state');
const keyvalLower = Gdk.keyval_to_lower(event.keyval);
const realMask = event.state & Gtk.accelerator_get_default_mod_mask();
this.alt_label.sensitive = !isAlt(keyvalLower) && (realMask & Gdk.ModifierType.MOD1_MASK);
this.ctrl_label.sensitive = !isCtrl(keyvalLower) && (realMask & Gdk.ModifierType.CONTROL_MASK);
this.shift_label.sensitive = !isShift(keyvalLower) && (realMask & Gdk.ModifierType.SHIFT_MASK);
this.super_label.sensitive = !isSuper(keyvalLower) && (realMask & Gdk.ModifierType.SUPER_MASK);
return super.vfunc_key_release_event(event);
}
vfunc_key_press_event(event) {
if (!this.plugin.state)
debug('ignoring remote keyboard state');
let keyvalLower = Gdk.keyval_to_lower(event.keyval);
let realMask = event.state & Gtk.accelerator_get_default_mod_mask();
this.alt_label.sensitive = isAlt(keyvalLower) || (realMask & Gdk.ModifierType.MOD1_MASK);
this.ctrl_label.sensitive = isCtrl(keyvalLower) || (realMask & Gdk.ModifierType.CONTROL_MASK);
this.shift_label.sensitive = isShift(keyvalLower) || (realMask & Gdk.ModifierType.SHIFT_MASK);
this.super_label.sensitive = isSuper(keyvalLower) || (realMask & Gdk.ModifierType.SUPER_MASK);
// Wait for a real key before sending
if (MOD_KEYS.includes(keyvalLower))
return false;
// Normalize Tab
if (keyvalLower === Gdk.KEY_ISO_Left_Tab)
keyvalLower = Gdk.KEY_Tab;
// Put shift back if it changed the case of the key, not otherwise.
if (keyvalLower !== event.keyval)
realMask |= Gdk.ModifierType.SHIFT_MASK;
// HACK: we don't want to use SysRq as a keybinding (but we do want
// Alt+Print), so we avoid translation from Alt+Print to SysRq
if (keyvalLower === Gdk.KEY_Sys_Req && (realMask & Gdk.ModifierType.MOD1_MASK) !== 0)
keyvalLower = Gdk.KEY_Print;
// CapsLock isn't supported as a keybinding modifier, so keep it from
// confusing us
realMask &= ~Gdk.ModifierType.LOCK_MASK;
if (keyvalLower === 0)
return false;
debug(`keyval: ${event.keyval}, mask: ${realMask}`);
const request = {
alt: !!(realMask & Gdk.ModifierType.MOD1_MASK),
ctrl: !!(realMask & Gdk.ModifierType.CONTROL_MASK),
shift: !!(realMask & Gdk.ModifierType.SHIFT_MASK),
super: !!(realMask & Gdk.ModifierType.SUPER_MASK),
sendAck: true,
};
// specialKey
if (ReverseKeyMap.has(event.keyval)) {
request.specialKey = ReverseKeyMap.get(event.keyval);
// key
} else {
const codePoint = Gdk.keyval_to_unicode(event.keyval);
request.key = String.fromCodePoint(codePoint);
}
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: request,
});
// Pass these key combinations rather than using the echo reply
if (request.alt || request.ctrl || request.super)
return super.vfunc_key_press_event(event);
return false;
}
vfunc_scroll_event(event) {
if (event.delta_x === 0 && event.delta_y === 0)
return true;
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
scroll: true,
dx: event.delta_x * 200,
dy: event.delta_y * 200,
},
});
return true;
}
vfunc_window_state_event(event) {
if (!this.plugin.state)
debug('ignoring remote keyboard state');
if (event.new_window_state & Gdk.WindowState.FOCUSED)
this._grab();
else
this._ungrab();
return super.vfunc_window_state_event(event);
}
_onInsertText(buffer, location, text, len) {
if (this._isAck)
return;
debug(`insert-text: ${text} (chars ${[...text].length})`);
for (const char of [...text]) {
if (!char)
continue;
// TODO: modifiers?
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
alt: false,
ctrl: false,
shift: false,
super: false,
sendAck: false,
key: char,
},
});
}
}
_onState(widget) {
if (!this.plugin.state)
debug('ignoring remote keyboard state');
if (this.is_active)
this._grab();
else
this._ungrab();
}
_grab() {
if (!this.visible || this._keyboard)
return;
const seat = Gdk.Display.get_default().get_default_seat();
const status = seat.grab(
this.get_window(),
Gdk.SeatCapabilities.KEYBOARD,
false,
null,
null,
null
);
if (status !== Gdk.GrabStatus.SUCCESS) {
logError(new Error('Grabbing keyboard failed'));
return;
}
this._keyboard = seat.get_keyboard();
this.grab_add();
this.entry.has_focus = true;
}
_ungrab() {
if (this._keyboard) {
this._keyboard.get_seat().ungrab();
this._keyboard = null;
this.grab_remove();
}
this.entry.buffer.text = '';
}
_resetTouchpadMotion() {
this.touchpad_motion_prev_x = 0;
this.touchpad_motion_prev_y = 0;
this.touchpad_motion_x = 0;
this.touchpad_motion_y = 0;
}
_onMouseLeftButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singleclick: true,
},
});
}
_onMouseMiddleButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
middleclick: true,
},
});
}
_onMouseRightButtonClicked(button) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
rightclick: true,
},
});
}
_onTouchpadDragBegin(gesture) {
this._resetTouchpadMotion();
this.touchpad_motion_timeout_id =
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 10,
this._onTouchpadMotionTimeout.bind(this));
}
_onTouchpadDragUpdate(gesture, offset_x, offset_y) {
this.touchpad_motion_x = offset_x;
this.touchpad_motion_y = offset_y;
}
_onTouchpadDragEnd(gesture) {
this._resetTouchpadMotion();
GLib.Source.remove(this.touchpad_motion_timeout_id);
this.touchpad_motion_timeout_id = 0;
}
_onTouchpadLongPressCancelled(gesture) {
const gesture_button = gesture.get_current_button();
// Check user dragged less than certain distances.
const is_click =
(Math.abs(this.touchpad_motion_x) < 4) &&
(Math.abs(this.touchpad_motion_y) < 4);
if (is_click) {
const click_body = {};
switch (gesture_button) {
case 1:
click_body.singleclick = true;
break;
case 2:
click_body.middleclick = true;
break;
case 3:
click_body.rightclick = true;
break;
default:
return;
}
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: click_body,
});
}
}
_onTouchpadLongPressPressed(gesture) {
const gesture_button = gesture.get_current_button();
if (gesture_button !== 1) {
debug('Long press on other type of buttons are not handled.');
} else {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singlehold: true,
},
});
this.touchpad_holding = true;
}
}
_onTouchpadLongPressEnd(gesture) {
if (this.touchpad_holding) {
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
singlerelease: true,
},
});
this.touchpad_holding = false;
}
}
_onTouchpadMotionTimeout() {
const diff_x = this.touchpad_motion_x - this.touchpad_motion_prev_x;
const diff_y = this.touchpad_motion_y - this.touchpad_motion_prev_y;
this.device.sendPacket({
type: 'kdeconnect.mousepad.request',
body: {
dx: diff_x,
dy: diff_y,
},
});
this.touchpad_motion_prev_x = this.touchpad_motion_x;
this.touchpad_motion_prev_y = this.touchpad_motion_y;
return true;
}
});

View File

@@ -0,0 +1,178 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import * as URI from '../utils/uri.js';
import '../utils/ui.js';
/**
* A dialog for repliable notifications.
*/
const ReplyDialog = GObject.registerClass({
GTypeName: 'GSConnectNotificationReplyDialog',
Properties: {
'device': GObject.ParamSpec.object(
'device',
'Device',
'The device associated with this window',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'plugin': GObject.ParamSpec.object(
'plugin',
'Plugin',
'The plugin that owns this notification',
GObject.ParamFlags.READWRITE,
GObject.Object
),
'uuid': GObject.ParamSpec.string(
'uuid',
'UUID',
'The notification reply UUID',
GObject.ParamFlags.READWRITE,
null
),
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/notification-reply-dialog.ui',
Children: ['infobar', 'notification-title', 'notification-body', 'entry'],
}, class ReplyDialog extends Gtk.Dialog {
_init(params) {
super._init({
application: Gio.Application.get_default(),
device: params.device,
plugin: params.plugin,
uuid: params.uuid,
use_header_bar: true,
});
this.set_response_sensitive(Gtk.ResponseType.OK, false);
// Info bar
this.device.bind_property(
'connected',
this.infobar,
'reveal-child',
GObject.BindingFlags.INVERT_BOOLEAN
);
// Notification Data
const headerbar = this.get_titlebar();
headerbar.title = params.notification.appName;
headerbar.subtitle = this.device.name;
this.notification_title.label = params.notification.title;
this.notification_body.label = URI.linkify(params.notification.text);
// Message Entry/Send Button
this.device.bind_property(
'connected',
this.entry,
'sensitive',
GObject.BindingFlags.DEFAULT
);
this._connectedId = this.device.connect(
'notify::connected',
this._onStateChanged.bind(this)
);
this._entryChangedId = this.entry.buffer.connect(
'changed',
this._onStateChanged.bind(this)
);
this.restoreGeometry('notification-reply-dialog');
this.connect('destroy', this._onDestroy);
}
_onDestroy(dialog) {
dialog.entry.buffer.disconnect(dialog._entryChangedId);
dialog.device.disconnect(dialog._connectedId);
}
vfunc_delete_event() {
this.saveGeometry();
return false;
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
// Refuse to send empty or whitespace only messages
if (!this.entry.buffer.text.trim())
return;
this.plugin.replyNotification(
this.uuid,
this.entry.buffer.text
);
}
this.destroy();
}
get device() {
if (this._device === undefined)
this._device = null;
return this._device;
}
set device(device) {
this._device = device;
}
get plugin() {
if (this._plugin === undefined)
this._plugin = null;
return this._plugin;
}
set plugin(plugin) {
this._plugin = plugin;
}
get uuid() {
if (this._uuid === undefined)
this._uuid = null;
return this._uuid;
}
set uuid(uuid) {
this._uuid = uuid;
// We must have a UUID
if (!uuid) {
this.destroy();
debug('no uuid for repliable notification');
}
}
_onActivateLink(label, uri) {
Gtk.show_uri_on_window(
this.get_toplevel(),
uri.includes('://') ? uri : `https://${uri}`,
Gtk.get_current_event_time()
);
return true;
}
_onStateChanged() {
if (this.device.connected && this.entry.buffer.text.trim())
this.set_response_sensitive(Gtk.ResponseType.OK, true);
else
this.set_response_sensitive(Gtk.ResponseType.OK, false);
}
});
export default ReplyDialog;

View File

@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';
import system from 'system';
import Config from '../../config.js';
/*
* Issue Header
*/
const ISSUE_HEADER = `
GSConnect: ${Config.PACKAGE_VERSION} (${Config.IS_USER ? 'user' : 'system'})
GJS: ${system.version}
Session: ${GLib.getenv('XDG_SESSION_TYPE')}
OS: ${GLib.get_os_info('PRETTY_NAME')}
`;
/**
* A dialog for selecting a device
*/
export const DeviceChooser = GObject.registerClass({
GTypeName: 'GSConnectServiceDeviceChooser',
Properties: {
'action-name': GObject.ParamSpec.string(
'action-name',
'Action Name',
'The name of the associated action, like "sendFile"',
GObject.ParamFlags.READWRITE,
null
),
'action-target': GObject.param_spec_variant(
'action-target',
'Action Target',
'The parameter for action invocations',
new GLib.VariantType('*'),
null,
GObject.ParamFlags.READWRITE
),
},
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/service-device-chooser.ui',
Children: ['device-list', 'cancel-button', 'select-button'],
}, class DeviceChooser extends Gtk.Dialog {
_init(params = {}) {
super._init({
use_header_bar: true,
application: Gio.Application.get_default(),
});
this.set_keep_above(true);
// HeaderBar
this.get_header_bar().subtitle = params.title;
// Dialog Action
this.action_name = params.action_name;
this.action_target = params.action_target;
// Device List
this.device_list.set_sort_func(this._sortDevices);
this._devicesChangedId = this.application.settings.connect(
'changed::devices',
this._onDevicesChanged.bind(this)
);
this._onDevicesChanged();
}
vfunc_response(response_id) {
if (response_id === Gtk.ResponseType.OK) {
try {
const device = this.device_list.get_selected_row().device;
device.activate_action(this.action_name, this.action_target);
} catch (e) {
logError(e);
}
}
this.destroy();
}
get action_name() {
if (this._action_name === undefined)
this._action_name = null;
return this._action_name;
}
set action_name(name) {
this._action_name = name;
}
get action_target() {
if (this._action_target === undefined)
this._action_target = null;
return this._action_target;
}
set action_target(variant) {
this._action_target = variant;
}
_onDeviceActivated(box, row) {
this.response(Gtk.ResponseType.OK);
}
_onDeviceSelected(box) {
this.set_response_sensitive(
Gtk.ResponseType.OK,
(box.get_selected_row())
);
}
_onDevicesChanged() {
// Collect known devices
const devices = {};
for (const [id, device] of this.application.manager.devices.entries())
devices[id] = device;
// Prune device rows
this.device_list.foreach(row => {
if (!devices.hasOwnProperty(row.name))
row.destroy();
else
delete devices[row.name];
});
// Add new devices
for (const device of Object.values(devices)) {
const action = device.lookup_action(this.action_name);
if (action === null)
continue;
const row = new Gtk.ListBoxRow({
visible: action.enabled,
});
row.set_name(device.id);
row.device = device;
action.bind_property(
'enabled',
row,
'visible',
Gio.SettingsBindFlags.DEFAULT
);
const grid = new Gtk.Grid({
column_spacing: 12,
margin: 6,
visible: true,
});
row.add(grid);
const icon = new Gtk.Image({
icon_name: device.icon_name,
pixel_size: 32,
visible: true,
});
grid.attach(icon, 0, 0, 1, 1);
const name = new Gtk.Label({
label: device.name,
halign: Gtk.Align.START,
hexpand: true,
visible: true,
});
grid.attach(name, 1, 0, 1, 1);
this.device_list.add(row);
}
if (this.device_list.get_selected_row() === null)
this.device_list.select_row(this.device_list.get_row_at_index(0));
}
_sortDevices(row1, row2) {
return row1.device.name.localeCompare(row2.device.name);
}
});
/**
* A dialog for reporting an error.
*/
export const ErrorDialog = GObject.registerClass({
GTypeName: 'GSConnectServiceErrorDialog',
Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/service-error-dialog.ui',
Children: [
'error-stack',
'expander-arrow',
'gesture',
'report-button',
'revealer',
],
}, class ErrorDialog extends Gtk.Window {
_init(error) {
super._init({
application: Gio.Application.get_default(),
title: `GSConnect: ${error.name}`,
});
this.set_keep_above(true);
this.error = error;
this.error_stack.buffer.text = `${error.message}\n\n${error.stack}`;
this.gesture.connect('released', this._onReleased.bind(this));
}
_onClicked(button) {
if (this.report_button === button) {
const uri = this._buildUri(this.error.message, this.error.stack);
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
this.destroy();
}
_onReleased(gesture, n_press) {
if (n_press === 1)
this.revealer.reveal_child = !this.revealer.reveal_child;
}
_onRevealChild(revealer, pspec) {
this.expander_arrow.icon_name = this.revealer.reveal_child
? 'pan-down-symbolic'
: 'pan-end-symbolic';
}
_buildUri(message, stack) {
const body = `\`\`\`${ISSUE_HEADER}\n${stack}\n\`\`\``;
const titleQuery = encodeURIComponent(message).replace('%20', '+');
const bodyQuery = encodeURIComponent(body).replace('%20', '+');
const uri = `${Config.PACKAGE_BUGREPORT}?title=${titleQuery}&body=${bodyQuery}`;
// Reasonable URI length limit
if (uri.length > 2000)
return uri.substr(0, 2000);
return uri;
}
});

View File

@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GjsPrivate from 'gi://GjsPrivate';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
/*
* Some utility methods
*/
function toDBusCase(string) {
return string.replace(/(?:^\w|[A-Z]|\b\w)/g, (ltr, offset) => {
return ltr.toUpperCase();
}).replace(/[\s_-]+/g, '');
}
function toUnderscoreCase(string) {
return string.replace(/(?:^\w|[A-Z]|_|\b\w)/g, (ltr, offset) => {
if (ltr === '_')
return '';
return (offset > 0) ? `_${ltr.toLowerCase()}` : ltr.toLowerCase();
}).replace(/[\s-]+/g, '');
}
/**
* DBus.Interface represents a DBus interface bound to an object instance, meant
* to be exported over DBus.
*/
export const Interface = GObject.registerClass({
GTypeName: 'GSConnectDBusInterface',
Implements: [Gio.DBusInterface],
Properties: {
'g-instance': GObject.ParamSpec.object(
'g-instance',
'Instance',
'The delegate GObject',
GObject.ParamFlags.READWRITE,
GObject.Object.$gtype
),
},
}, class Interface extends GjsPrivate.DBusImplementation {
_init(params) {
super._init({
g_instance: params.g_instance,
g_interface_info: params.g_interface_info,
});
// Cache member lookups
this._instanceHandlers = [];
this._instanceMethods = {};
this._instanceProperties = {};
const info = this.get_info();
this.connect('handle-method-call', this._call.bind(this._instance, info));
this.connect('handle-property-get', this._get.bind(this._instance, info));
this.connect('handle-property-set', this._set.bind(this._instance, info));
// Automatically forward known signals
const id = this._instance.connect('notify', this._notify.bind(this));
this._instanceHandlers.push(id);
for (const signal of info.signals) {
const type = `(${signal.args.map(arg => arg.signature).join('')})`;
const id = this._instance.connect(
signal.name,
this._emit.bind(this, signal.name, type)
);
this._instanceHandlers.push(id);
}
// Export if connection and object path were given
if (params.g_connection && params.g_object_path)
this.export(params.g_connection, params.g_object_path);
}
get g_instance() {
if (this._instance === undefined)
this._instance = null;
return this._instance;
}
set g_instance(instance) {
this._instance = instance;
}
/**
* Invoke an instance's method for a DBus method call.
*
* @param {Gio.DBusInterfaceInfo} info - The DBus interface
* @param {DBus.Interface} iface - The DBus interface
* @param {string} name - The DBus method name
* @param {GLib.Variant} parameters - The method parameters
* @param {Gio.DBusMethodInvocation} invocation - The method invocation info
*/
async _call(info, iface, name, parameters, invocation) {
let retval;
// Invoke the instance method
try {
const args = parameters.unpack().map(parameter => {
if (parameter.get_type_string() === 'h') {
const message = invocation.get_message();
const fds = message.get_unix_fd_list();
const idx = parameter.deepUnpack();
return fds.get(idx);
} else {
return parameter.recursiveUnpack();
}
});
retval = await this[name](...args);
} catch (e) {
if (e instanceof GLib.Error) {
invocation.return_gerror(e);
} else {
// likely to be a normal JS error
if (!e.name.includes('.'))
e.name = `org.gnome.gjs.JSError.${e.name}`;
invocation.return_dbus_error(e.name, e.message);
}
logError(e, `${this}: ${name}`);
return;
}
// `undefined` is an empty tuple on DBus
if (retval === undefined)
retval = new GLib.Variant('()', []);
// Return the instance result or error
try {
if (!(retval instanceof GLib.Variant)) {
const args = info.lookup_method(name).out_args;
retval = new GLib.Variant(
`(${args.map(arg => arg.signature).join('')})`,
(args.length === 1) ? [retval] : retval
);
}
invocation.return_value(retval);
} catch (e) {
invocation.return_dbus_error(
'org.gnome.gjs.JSError.ValueError',
'Service implementation returned an incorrect value type'
);
logError(e, `${this}: ${name}`);
}
}
_nativeProp(obj, name) {
if (this._instanceProperties[name] === undefined) {
let propName = name;
if (propName in obj)
this._instanceProperties[name] = propName;
if (this._instanceProperties[name] === undefined) {
propName = toUnderscoreCase(name);
if (propName in obj)
this._instanceProperties[name] = propName;
}
}
return this._instanceProperties[name];
}
_emit(name, type, obj, ...args) {
this.emit_signal(name, new GLib.Variant(type, args));
}
_get(info, iface, name) {
const nativeValue = this[iface._nativeProp(this, name)];
const propertyInfo = info.lookup_property(name);
if (nativeValue === undefined || propertyInfo === null)
return null;
return new GLib.Variant(propertyInfo.signature, nativeValue);
}
_set(info, iface, name, value) {
const nativeValue = value.recursiveUnpack();
this[iface._nativeProp(this, name)] = nativeValue;
}
_notify(obj, pspec) {
const name = toDBusCase(pspec.name);
const propertyInfo = this.get_info().lookup_property(name);
if (propertyInfo === null)
return;
this.emit_property_changed(
name,
new GLib.Variant(
propertyInfo.signature,
// Adjust for GJS's '-'/'_' conversion
this._instance[pspec.name.replace(/-/gi, '_')]
)
);
}
destroy() {
try {
for (const id of this._instanceHandlers)
this._instance.disconnect(id);
this._instanceHandlers = [];
this.flush();
this.unexport();
} catch (e) {
logError(e);
}
}
});
/**
* Get a new, dedicated DBus connection on @busType
*
* @param {Gio.BusType} [busType] - a Gio.BusType constant
* @param {Gio.Cancellable} [cancellable] - an optional Gio.Cancellable
* @return {Promise<Gio.DBusConnection>} A new DBus connection
*/
export function newConnection(busType = Gio.BusType.SESSION, cancellable = null) {
return new Promise((resolve, reject) => {
Gio.DBusConnection.new_for_address(
Gio.dbus_address_get_for_bus_sync(busType, cancellable),
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
null,
cancellable,
(connection, res) => {
try {
resolve(Gio.DBusConnection.new_for_address_finish(res));
} catch (e) {
reject(e);
}
}
);
});
}

View File

@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import Config from '../../config.js';
/*
* Window State
*/
Gtk.Window.prototype.restoreGeometry = function (context = 'default') {
this._windowState = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect.WindowState',
true
),
path: `/org/gnome/shell/extensions/gsconnect/${context}/`,
});
// Size
const [width, height] = this._windowState.get_value('window-size').deepUnpack();
if (width && height)
this.set_default_size(width, height);
// Maximized State
if (this._windowState.get_boolean('window-maximized'))
this.maximize();
};
Gtk.Window.prototype.saveGeometry = function () {
const state = this.get_window().get_state();
// Maximized State
const maximized = (state & Gdk.WindowState.MAXIMIZED);
this._windowState.set_boolean('window-maximized', maximized);
// Leave the size at the value before maximizing
if (maximized || (state & Gdk.WindowState.FULLSCREEN))
return;
// Size
const size = this.get_size();
this._windowState.set_value('window-size', new GLib.Variant('(ii)', size));
};

View File

@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import GLib from 'gi://GLib';
/**
* The same regular expression used in GNOME Shell
*
* http://daringfireball.net/2010/07/improved_regex_for_matching_urls
*/
const _balancedParens = '\\((?:[^\\s()<>]+|(?:\\(?:[^\\s()<>]+\\)))*\\)';
const _leadingJunk = '[\\s`(\\[{\'\\"<\u00AB\u201C\u2018]';
const _notTrailingJunk = '[^\\s`!()\\[\\]{};:\'\\".,<>?\u00AB\u00BB\u201C\u201D\u2018\u2019]';
const _urlRegexp = new RegExp(
'(^|' + _leadingJunk + ')' +
'(' +
'(?:' +
'(?:http|https)://' + // scheme://
'|' +
'www\\d{0,3}[.]' + // www.
'|' +
'[a-z0-9.\\-]+[.][a-z]{2,4}/' + // foo.xx/
')' +
'(?:' + // one or more:
'[^\\s()<>]+' + // run of non-space non-()
'|' + // or
_balancedParens + // balanced parens
')+' +
'(?:' + // end with:
_balancedParens + // balanced parens
'|' + // or
_notTrailingJunk + // last non-junk char
')' +
')', 'gi');
/**
* sms/tel URI RegExp (https://tools.ietf.org/html/rfc5724)
*
* A fairly lenient regexp for sms: URIs that allows tel: numbers with chars
* from global-number, local-number (without phone-context) and single spaces.
* This allows passing numbers directly from libfolks or GData without
* pre-processing. It also makes an allowance for URIs passed from Gio.File
* that always come in the form "sms:///".
*/
const _smsParam = "[\\w.!~*'()-]+=(?:[\\w.!~*'()-]|%[0-9A-F]{2})*";
const _telParam = ";[a-zA-Z0-9-]+=(?:[\\w\\[\\]/:&+$.!~*'()-]|%[0-9A-F]{2})+";
const _lenientDigits = '[+]?(?:[0-9A-F*#().-]| (?! )|%20(?!%20))+';
const _lenientNumber = `${_lenientDigits}(?:${_telParam})*`;
const _smsRegex = new RegExp(
'^' +
'sms:' + // scheme
'(?:[/]{2,3})?' + // Gio.File returns ":///"
'(' + // one or more...
_lenientNumber + // phone numbers
'(?:,' + _lenientNumber + ')*' + // separated by commas
')' +
'(?:\\?(' + // followed by optional...
_smsParam + // parameters...
'(?:&' + _smsParam + ')*' + // separated by "&" (unescaped)
'))?' +
'$', 'g'); // fragments (#foo) not allowed
const _numberRegex = new RegExp(
'^' +
'(' + _lenientDigits + ')' + // phone number digits
'((?:' + _telParam + ')*)' + // followed by optional parameters
'$', 'g');
/**
* Searches @str for URLs and returns an array of objects with %url
* properties showing the matched URL string, and %pos properties indicating
* the position within @str where the URL was found.
*
* @param {string} str - the string to search
* @return {Object[]} the list of match objects, as described above
*/
export function findUrls(str) {
_urlRegexp.lastIndex = 0;
const res = [];
let match;
while ((match = _urlRegexp.exec(str))) {
const name = match[2];
const url = GLib.uri_parse_scheme(name) ? name : `http://${name}`;
res.push({name, url, pos: match.index + match[1].length});
}
return res;
}
/**
* Return a string with URLs couched in <a> tags, parseable by Pango and
* using the same RegExp as GNOME Shell.
*
* @param {string} str - The string to be modified
* @param {string} [title] - An optional title (eg. alt text, tooltip)
* @return {string} the modified text
*/
export function linkify(str, title = null) {
const text = GLib.markup_escape_text(str, -1);
_urlRegexp.lastIndex = 0;
if (title) {
return text.replace(
_urlRegexp,
`$1<a href="$2" title="${title}">$2</a>`
);
} else {
return text.replace(_urlRegexp, '$1<a href="$2">$2</a>');
}
}
/**
* A simple parsing class for sms: URI's (https://tools.ietf.org/html/rfc5724)
*/
export default class URI {
constructor(uri) {
_smsRegex.lastIndex = 0;
const [, recipients, query] = _smsRegex.exec(uri);
this.recipients = recipients.split(',').map(recipient => {
_numberRegex.lastIndex = 0;
const [, number, params] = _numberRegex.exec(recipient);
if (params) {
for (const param of params.substr(1).split(';')) {
const [key, value] = param.split('=');
// add phone-context to beginning of
if (key === 'phone-context' && value.startsWith('+'))
return value + unescape(number);
}
}
return unescape(number);
});
if (query) {
for (const field of query.split('&')) {
const [key, value] = field.split('=');
if (key === 'body') {
if (this.body)
throw URIError('duplicate "body" field');
this.body = value ? decodeURIComponent(value) : undefined;
}
}
}
}
toString() {
const uri = `sms:${this.recipients.join(',')}`;
return this.body ? `${uri}?body=${escape(this.body)}` : uri;
}
}

View File

@@ -0,0 +1,380 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GjsPrivate from 'gi://GjsPrivate';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
/*
* DBus Interface Info
*/
const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(`
<node>
<interface name="org.gnome.Shell.Extensions.GSConnect.Clipboard">
<!-- Methods -->
<method name="GetMimetypes">
<arg direction="out" type="as" name="mimetypes"/>
</method>
<method name="GetText">
<arg direction="out" type="s" name="text"/>
</method>
<method name="SetText">
<arg direction="in" type="s" name="text"/>
</method>
<method name="GetValue">
<arg direction="in" type="s" name="mimetype"/>
<arg direction="out" type="ay" name="value"/>
</method>
<method name="SetValue">
<arg direction="in" type="ay" name="value"/>
<arg direction="in" type="s" name="mimetype"/>
</method>
<!-- Signals -->
<signal name="OwnerChange"/>
</interface>
</node>
`);
const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);
/*
* Text Mimetypes
*/
const TEXT_MIMETYPES = [
'text/plain;charset=utf-8',
'UTF8_STRING',
'text/plain',
'STRING',
];
/* GSConnectClipboardPortal:
*
* A simple clipboard portal, especially useful on Wayland where GtkClipboard
* doesn't work in the background.
*/
export const Clipboard = GObject.registerClass({
GTypeName: 'GSConnectShellClipboard',
}, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {
_init(params = {}) {
super._init({
g_interface_info: DBUS_INFO,
});
this._transferring = false;
// Watch global selection
this._selection = global.display.get_selection();
this._ownerChangedId = this._selection.connect(
'owner-changed',
this._onOwnerChanged.bind(this)
);
// Prepare DBus interface
this._handleMethodCallId = this.connect(
'handle-method-call',
this._onHandleMethodCall.bind(this)
);
this._nameId = Gio.DBus.own_name(
Gio.BusType.SESSION,
DBUS_NAME,
Gio.BusNameOwnerFlags.NONE,
this._onBusAcquired.bind(this),
null,
this._onNameLost.bind(this)
);
}
_onOwnerChanged(selection, type, source) {
/* We're only interested in the standard clipboard */
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD)
return;
/* In Wayland an intermediate GMemoryOutputStream is used which triggers
* a second ::owner-changed emission, so we need to ensure we ignore
* that while the transfer is resolving.
*/
if (this._transferring)
return;
this._transferring = true;
/* We need to put our signal emission in an idle callback to ensure that
* Mutter's internal calls have finished resolving in the loop, or else
* we'll end up with the previous selection's content.
*/
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
this.emit_signal('OwnerChange', null);
this._transferring = false;
return GLib.SOURCE_REMOVE;
});
}
_onBusAcquired(connection, name) {
try {
this.export(connection, DBUS_PATH);
} catch (e) {
logError(e);
}
}
_onNameLost(connection, name) {
try {
this.unexport();
} catch (e) {
logError(e);
}
}
async _onHandleMethodCall(iface, name, parameters, invocation) {
let retval;
try {
const args = parameters.recursiveUnpack();
retval = await this[name](...args);
} catch (e) {
if (e instanceof GLib.Error) {
invocation.return_gerror(e);
} else {
if (!e.name.includes('.'))
e.name = `org.gnome.gjs.JSError.${e.name}`;
invocation.return_dbus_error(e.name, e.message);
}
return;
}
if (retval === undefined)
retval = new GLib.Variant('()', []);
try {
if (!(retval instanceof GLib.Variant)) {
const args = DBUS_INFO.lookup_method(name).out_args;
retval = new GLib.Variant(
`(${args.map(arg => arg.signature).join('')})`,
(args.length === 1) ? [retval] : retval
);
}
invocation.return_value(retval);
// Without a response, the client will wait for timeout
} catch (e) {
invocation.return_dbus_error(
'org.gnome.gjs.JSError.ValueError',
'Service implementation returned an incorrect value type'
);
}
}
/**
* Get the available mimetypes of the current clipboard content
*
* @return {Promise<string[]>} A list of mime-types
*/
GetMimetypes() {
return new Promise((resolve, reject) => {
try {
const mimetypes = this._selection.get_mimetypes(
Meta.SelectionType.SELECTION_CLIPBOARD
);
resolve(mimetypes);
} catch (e) {
reject(e);
}
});
}
/**
* Get the text content of the clipboard
*
* @return {Promise<string>} Text content of the clipboard
*/
GetText() {
return new Promise((resolve, reject) => {
const mimetypes = this._selection.get_mimetypes(
Meta.SelectionType.SELECTION_CLIPBOARD);
const mimetype = TEXT_MIMETYPES.find(type => mimetypes.includes(type));
if (mimetype !== undefined) {
const stream = Gio.MemoryOutputStream.new_resizable();
this._selection.transfer_async(
Meta.SelectionType.SELECTION_CLIPBOARD,
mimetype, -1,
stream, null,
(selection, res) => {
try {
selection.transfer_finish(res);
const bytes = stream.steal_as_bytes();
const bytearray = bytes.get_data();
resolve(new TextDecoder().decode(bytearray));
} catch (e) {
reject(e);
}
}
);
} else {
reject(new Error('text not available'));
}
});
}
/**
* Set the text content of the clipboard
*
* @param {string} text - text content to set
* @return {Promise} A promise for the operation
*/
SetText(text) {
return new Promise((resolve, reject) => {
try {
if (typeof text !== 'string') {
throw new Gio.DBusError({
code: Gio.DBusError.INVALID_ARGS,
message: 'expected string',
});
}
const source = Meta.SelectionSourceMemory.new(
'text/plain;charset=utf-8', GLib.Bytes.new(text));
this._selection.set_owner(
Meta.SelectionType.SELECTION_CLIPBOARD, source);
resolve();
} catch (e) {
reject(e);
}
});
}
/**
* Get the content of the clipboard with the type @mimetype.
*
* @param {string} mimetype - the mimetype to request
* @return {Promise<Uint8Array>} The content of the clipboard
*/
GetValue(mimetype) {
return new Promise((resolve, reject) => {
const stream = Gio.MemoryOutputStream.new_resizable();
this._selection.transfer_async(
Meta.SelectionType.SELECTION_CLIPBOARD,
mimetype, -1,
stream, null,
(selection, res) => {
try {
selection.transfer_finish(res);
const bytes = stream.steal_as_bytes();
resolve(bytes.get_data());
} catch (e) {
reject(e);
}
}
);
});
}
/**
* Set the content of the clipboard to @value with the type @mimetype.
*
* @param {Uint8Array} value - the value to set
* @param {string} mimetype - the mimetype of the value
* @return {Promise} - A promise for the operation
*/
SetValue(value, mimetype) {
return new Promise((resolve, reject) => {
try {
const source = Meta.SelectionSourceMemory.new(mimetype,
GLib.Bytes.new(value));
this._selection.set_owner(
Meta.SelectionType.SELECTION_CLIPBOARD, source);
resolve();
} catch (e) {
reject(e);
}
});
}
destroy() {
if (this._selection && this._ownerChangedId > 0) {
this._selection.disconnect(this._ownerChangedId);
this._ownerChangedId = 0;
}
if (this._nameId > 0) {
Gio.bus_unown_name(this._nameId);
this._nameId = 0;
}
if (this._handleMethodCallId > 0) {
this.disconnect(this._handleMethodCallId);
this._handleMethodCallId = 0;
this.unexport();
}
}
});
let _portal = null;
let _portalId = 0;
/**
* Watch for the service to start and export the clipboard portal when it does.
*/
export function watchService() {
if (GLib.getenv('XDG_SESSION_TYPE') !== 'wayland')
return;
if (_portalId > 0)
return;
_portalId = Gio.bus_watch_name(
Gio.BusType.SESSION,
'org.gnome.Shell.Extensions.GSConnect',
Gio.BusNameWatcherFlags.NONE,
() => {
if (_portal === null)
_portal = new Clipboard();
},
() => {
if (_portal !== null) {
_portal.destroy();
_portal = null;
}
}
);
}
/**
* Stop watching the service and export the portal if currently running.
*/
export function unwatchService() {
if (_portalId > 0) {
Gio.bus_unwatch_name(_portalId);
_portalId = 0;
}
}

View File

@@ -0,0 +1,380 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
import {getIcon} from './utils.js';
import * as GMenu from './gmenu.js';
import Tooltip from './tooltip.js';
/**
* A battery widget with an icon, text percentage and time estimate tooltip
*/
export const Battery = GObject.registerClass({
GTypeName: 'GSConnectShellDeviceBattery',
}, class Battery extends St.BoxLayout {
_init(params) {
super._init({
reactive: true,
style_class: 'gsconnect-device-battery',
track_hover: true,
});
Object.assign(this, params);
// Percent Label
this.label = new St.Label({
y_align: Clutter.ActorAlign.CENTER,
});
this.label.clutter_text.ellipsize = 0;
this.add_child(this.label);
// Battery Icon
this.icon = new St.Icon({
fallback_icon_name: 'battery-missing-symbolic',
icon_size: 16,
});
this.add_child(this.icon);
// Battery Estimate
this.tooltip = new Tooltip({
parent: this,
text: null,
});
// Battery GAction
this._actionAddedId = this.device.action_group.connect(
'action-added',
this._onActionChanged.bind(this)
);
this._actionRemovedId = this.device.action_group.connect(
'action-removed',
this._onActionChanged.bind(this)
);
this._actionStateChangedId = this.device.action_group.connect(
'action-state-changed',
this._onStateChanged.bind(this)
);
this._onActionChanged(this.device.action_group, 'battery');
// Cleanup on destroy
this.connect('destroy', this._onDestroy);
}
_onActionChanged(action_group, action_name) {
if (action_name !== 'battery')
return;
if (action_group.has_action('battery')) {
const value = action_group.get_action_state('battery');
const [charging, icon_name, level, time] = value.deepUnpack();
this._state = {
charging: charging,
icon_name: icon_name,
level: level,
time: time,
};
} else {
this._state = null;
}
this._sync();
}
_onStateChanged(action_group, action_name, value) {
if (action_name !== 'battery')
return;
const [charging, icon_name, level, time] = value.deepUnpack();
this._state = {
charging: charging,
icon_name: icon_name,
level: level,
time: time,
};
this._sync();
}
_getBatteryLabel() {
if (!this._state)
return null;
const {charging, level, time} = this._state;
if (level === 100)
// TRANSLATORS: When the battery level is 100%
return _('Fully Charged');
if (time === 0)
// TRANSLATORS: When no time estimate for the battery is available
// EXAMPLE: 42% (Estimating…)
return _('%d%% (Estimating…)').format(level);
const total = time / 60;
const minutes = Math.floor(total % 60);
const hours = Math.floor(total / 60);
if (charging) {
// TRANSLATORS: Estimated time until battery is charged
// EXAMPLE: 42% (1:15 Until Full)
return _('%d%% (%d\u2236%02d Until Full)').format(
level,
hours,
minutes
);
} else {
// TRANSLATORS: Estimated time until battery is empty
// EXAMPLE: 42% (12:15 Remaining)
return _('%d%% (%d\u2236%02d Remaining)').format(
level,
hours,
minutes
);
}
}
_onDestroy(actor) {
actor.device.action_group.disconnect(actor._actionAddedId);
actor.device.action_group.disconnect(actor._actionRemovedId);
actor.device.action_group.disconnect(actor._actionStateChangedId);
}
_sync() {
this.visible = !!this._state;
if (!this.visible)
return;
this.icon.icon_name = this._state.icon_name;
this.label.text = (this._state.level > -1) ? `${this._state.level}%` : '';
this.tooltip.text = this._getBatteryLabel();
}
});
/**
* A cell signal strength widget with two icons
*/
export const SignalStrength = GObject.registerClass({
GTypeName: 'GSConnectShellDeviceSignalStrength',
}, class SignalStrength extends St.BoxLayout {
_init(params) {
super._init({
reactive: true,
style_class: 'gsconnect-device-signal-strength',
track_hover: true,
});
Object.assign(this, params);
// Network Type Icon
this.networkTypeIcon = new St.Icon({
fallback_icon_name: 'network-cellular-symbolic',
icon_size: 16,
});
this.add_child(this.networkTypeIcon);
// Signal Strength Icon
this.signalStrengthIcon = new St.Icon({
fallback_icon_name: 'network-cellular-offline-symbolic',
icon_size: 16,
});
this.add_child(this.signalStrengthIcon);
// Network Type Text
this.tooltip = new Tooltip({
parent: this,
text: null,
});
// ConnectivityReport GAction
this._actionAddedId = this.device.action_group.connect(
'action-added',
this._onActionChanged.bind(this)
);
this._actionRemovedId = this.device.action_group.connect(
'action-removed',
this._onActionChanged.bind(this)
);
this._actionStateChangedId = this.device.action_group.connect(
'action-state-changed',
this._onStateChanged.bind(this)
);
this._onActionChanged(this.device.action_group, 'connectivityReport');
// Cleanup on destroy
this.connect('destroy', this._onDestroy);
}
_onActionChanged(action_group, action_name) {
if (action_name !== 'connectivityReport')
return;
if (action_group.has_action('connectivityReport')) {
const value = action_group.get_action_state('connectivityReport');
const [
cellular_network_type,
cellular_network_type_icon,
cellular_network_strength,
cellular_network_strength_icon,
hotspot_name,
hotspot_bssid,
] = value.deepUnpack();
this._state = {
cellular_network_type: cellular_network_type,
cellular_network_type_icon: cellular_network_type_icon,
cellular_network_strength: cellular_network_strength,
cellular_network_strength_icon: cellular_network_strength_icon,
hotspot_name: hotspot_name,
hotspot_bssid: hotspot_bssid,
};
} else {
this._state = null;
}
this._sync();
}
_onStateChanged(action_group, action_name, value) {
if (action_name !== 'connectivityReport')
return;
const [
cellular_network_type,
cellular_network_type_icon,
cellular_network_strength,
cellular_network_strength_icon,
hotspot_name,
hotspot_bssid,
] = value.deepUnpack();
this._state = {
cellular_network_type: cellular_network_type,
cellular_network_type_icon: cellular_network_type_icon,
cellular_network_strength: cellular_network_strength,
cellular_network_strength_icon: cellular_network_strength_icon,
hotspot_name: hotspot_name,
hotspot_bssid: hotspot_bssid,
};
this._sync();
}
_onDestroy(actor) {
actor.device.action_group.disconnect(actor._actionAddedId);
actor.device.action_group.disconnect(actor._actionRemovedId);
actor.device.action_group.disconnect(actor._actionStateChangedId);
}
_sync() {
this.visible = !!this._state;
if (!this.visible)
return;
this.networkTypeIcon.icon_name = this._state.cellular_network_type_icon;
this.signalStrengthIcon.icon_name = this._state.cellular_network_strength_icon;
this.tooltip.text = this._state.cellular_network_type;
}
});
/**
* A PopupMenu used as an information and control center for a device
*/
export class Menu extends PopupMenu.PopupMenuSection {
constructor(params) {
super();
Object.assign(this, params);
this.actor.add_style_class_name('gsconnect-device-menu');
// Title
this._title = new PopupMenu.PopupSeparatorMenuItem(this.device.name);
this.addMenuItem(this._title);
// Title -> Name
this._title.label.style_class = 'gsconnect-device-name';
this._title.label.clutter_text.ellipsize = 0;
this.device.bind_property(
'name',
this._title.label,
'text',
GObject.BindingFlags.SYNC_CREATE
);
// Title -> Cellular Signal Strength
this._signalStrength = new SignalStrength({device: this.device});
this._title.actor.add_child(this._signalStrength);
// Title -> Battery
this._battery = new Battery({device: this.device});
this._title.actor.add_child(this._battery);
// Actions
let actions;
if (this.menu_type === 'icon') {
actions = new GMenu.IconBox({
action_group: this.device.action_group,
model: this.device.menu,
});
} else if (this.menu_type === 'list') {
actions = new GMenu.ListBox({
action_group: this.device.action_group,
model: this.device.menu,
});
}
this.addMenuItem(actions);
}
isEmpty() {
return false;
}
}
/**
* An indicator representing a Device in the Status Area
*/
export const Indicator = GObject.registerClass({
GTypeName: 'GSConnectDeviceIndicator',
}, class Indicator extends PanelMenu.Button {
_init(params) {
super._init(0.0, `${params.device.name} Indicator`, false);
Object.assign(this, params);
// Device Icon
this._icon = new St.Icon({
gicon: getIcon(this.device.icon_name),
style_class: 'system-status-icon gsconnect-device-indicator',
});
this.add_child(this._icon);
// Menu
const menu = new Menu({
device: this.device,
menu_type: 'icon',
});
this.menu.addMenuItem(menu);
}
});

View File

@@ -0,0 +1,647 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Atk from 'gi://Atk';
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import {getIcon} from './utils.js';
import Tooltip from './tooltip.js';
/**
* Get a dictionary of a GMenuItem's attributes
*
* @param {Gio.MenuModel} model - The menu model containing the item
* @param {number} index - The index of the item in @model
* @return {Object} A dictionary of the item's attributes
*/
function getItemInfo(model, index) {
const info = {
target: null,
links: [],
};
//
let iter = model.iterate_item_attributes(index);
while (iter.next()) {
const name = iter.get_name();
let value = iter.get_value();
switch (name) {
case 'icon':
value = Gio.Icon.deserialize(value);
if (value instanceof Gio.ThemedIcon)
value = getIcon(value.names[0]);
info[name] = value;
break;
case 'target':
info[name] = value;
break;
default:
info[name] = value.unpack();
}
}
// Submenus & Sections
iter = model.iterate_item_links(index);
while (iter.next()) {
info.links.push({
name: iter.get_name(),
value: iter.get_value(),
});
}
return info;
}
/**
*
*/
export class ListBox extends PopupMenu.PopupMenuSection {
constructor(params) {
super();
Object.assign(this, params);
// Main Actor
this.actor = new St.BoxLayout({
x_expand: true,
clip_to_allocation: true,
});
this.actor._delegate = this;
// Item Box
this.box.clip_to_allocation = true;
this.box.x_expand = true;
this.box.add_style_class_name('gsconnect-list-box');
this.box.set_pivot_point(1, 1);
this.actor.add_child(this.box);
// Submenu Container
this.sub = new St.BoxLayout({
clip_to_allocation: true,
vertical: false,
visible: false,
x_expand: true,
});
this.sub.set_pivot_point(1, 1);
this.sub._delegate = this;
this.actor.add_child(this.sub);
// Handle transitions
this._boxTransitionsCompletedId = this.box.connect(
'transitions-completed',
this._onTransitionsCompleted.bind(this)
);
this._subTransitionsCompletedId = this.sub.connect(
'transitions-completed',
this._onTransitionsCompleted.bind(this)
);
// Handle keyboard navigation
this._submenuCloseKeyId = this.sub.connect(
'key-press-event',
this._onSubmenuCloseKey.bind(this)
);
// Refresh the menu when mapped
this._mappedId = this.actor.connect(
'notify::mapped',
this._onMapped.bind(this)
);
// Watch the model for changes
this._itemsChangedId = this.model.connect(
'items-changed',
this._onItemsChanged.bind(this)
);
this._onItemsChanged();
}
_onMapped(actor) {
if (actor.mapped) {
this._onItemsChanged();
// We use this instead of close() to avoid touching finalized objects
} else {
this.box.set_opacity(255);
this.box.set_width(-1);
this.box.set_height(-1);
this.box.visible = true;
this._submenu = null;
this.sub.set_opacity(0);
this.sub.set_width(0);
this.sub.set_height(0);
this.sub.visible = false;
this.sub.get_children().map(menu => menu.hide());
}
}
_onSubmenuCloseKey(actor, event) {
if (this.submenu && event.get_key_symbol() === Clutter.KEY_Left) {
this.submenu.submenu_for.setActive(true);
this.submenu = null;
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
}
_onSubmenuOpenKey(actor, event) {
const item = actor._delegate;
if (item.submenu && event.get_key_symbol() === Clutter.KEY_Right) {
this.submenu = item.submenu;
item.submenu.firstMenuItem.setActive(true);
}
return Clutter.EVENT_PROPAGATE;
}
_onGMenuItemActivate(item, event) {
this.emit('activate', item);
if (item.submenu) {
this.submenu = item.submenu;
} else if (item.action_name) {
this.action_group.activate_action(
item.action_name,
item.action_target
);
this.itemActivated();
}
}
_addGMenuItem(info) {
const item = new PopupMenu.PopupMenuItem(info.label);
this.addMenuItem(item);
if (info.action !== undefined) {
item.action_name = info.action.split('.')[1];
item.action_target = info.target;
item.actor.visible = this.action_group.get_action_enabled(
item.action_name
);
}
item.connectObject(
'activate',
this._onGMenuItemActivate.bind(this),
this
);
return item;
}
_addGMenuSection(model) {
const section = new ListBox({
model: model,
action_group: this.action_group,
});
this.addMenuItem(section);
}
_addGMenuSubmenu(model, item) {
// Add an expander arrow to the item
const arrow = PopupMenu.arrowIcon(St.Side.RIGHT);
arrow.x_align = Clutter.ActorAlign.END;
arrow.x_expand = true;
item.actor.add_child(arrow);
// Mark it as an expandable and open on right-arrow
item.actor.add_accessible_state(Atk.StateType.EXPANDABLE);
item.actor.connect(
'key-press-event',
this._onSubmenuOpenKey.bind(this)
);
// Create the submenu
item.submenu = new ListBox({
model: model,
action_group: this.action_group,
submenu_for: item,
_parent: this,
});
item.submenu.actor.hide();
// Add to the submenu container
this.sub.add_child(item.submenu.actor);
}
_onItemsChanged(model, position, removed, added) {
// Clear the menu
this.removeAll();
this.sub.get_children().map(child => child.destroy());
for (let i = 0, len = this.model.get_n_items(); i < len; i++) {
const info = getItemInfo(this.model, i);
let item;
// A regular item
if (info.hasOwnProperty('label'))
item = this._addGMenuItem(info);
for (const link of info.links) {
// Submenu
if (link.name === 'submenu') {
this._addGMenuSubmenu(link.value, item);
// Section
} else if (link.name === 'section') {
this._addGMenuSection(link.value);
// len is length starting at 1
if (i + 1 < len)
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
}
}
}
// If this is a submenu of another item...
if (this.submenu_for) {
// Prepend an "<= Go Back" item, bold with a unicode arrow
const prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
prev.label.style = 'font-weight: bold;';
const prevArrow = PopupMenu.arrowIcon(St.Side.LEFT);
prev.replace_child(prev._ornamentIcon, prevArrow);
this.addMenuItem(prev, 0);
prev.connectObject('activate', (item, event) => {
this.emit('activate', item);
this._parent.submenu = null;
}, this);
}
}
_onTransitionsCompleted(actor) {
if (this.submenu) {
this.box.visible = false;
} else {
this.sub.visible = false;
this.sub.get_children().map(menu => menu.hide());
}
}
get submenu() {
return this._submenu || null;
}
set submenu(submenu) {
// Get the current allocation to hold the menu width
const allocation = this.actor.allocation;
const width = Math.max(0, allocation.x2 - allocation.x1);
// Prepare the appropriate child for tweening
if (submenu) {
this.sub.set_opacity(0);
this.sub.set_width(0);
this.sub.set_height(0);
this.sub.visible = true;
} else {
this.box.set_opacity(0);
this.box.set_width(0);
this.sub.set_height(0);
this.box.visible = true;
}
// Setup the animation
this.box.save_easing_state();
this.box.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
this.box.set_easing_duration(250);
this.sub.save_easing_state();
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
this.sub.set_easing_duration(250);
if (submenu) {
submenu.actor.show();
this.sub.set_opacity(255);
this.sub.set_width(width);
this.sub.set_height(-1);
this.box.set_opacity(0);
this.box.set_width(0);
this.box.set_height(0);
} else {
this.box.set_opacity(255);
this.box.set_width(width);
this.box.set_height(-1);
this.sub.set_opacity(0);
this.sub.set_width(0);
this.sub.set_height(0);
}
// Reset the animation
this.box.restore_easing_state();
this.sub.restore_easing_state();
//
this._submenu = submenu;
}
destroy() {
this.actor.disconnect(this._mappedId);
this.box.disconnect(this._boxTransitionsCompletedId);
this.sub.disconnect(this._subTransitionsCompletedId);
this.sub.disconnect(this._submenuCloseKeyId);
this.model.disconnect(this._itemsChangedId);
super.destroy();
}
}
/**
* A St.Button subclass for iconic GMenu items
*/
export const IconButton = GObject.registerClass({
GTypeName: 'GSConnectShellIconButton',
}, class Button extends St.Button {
_init(params) {
super._init({
style_class: 'gsconnect-icon-button',
can_focus: true,
});
Object.assign(this, params);
// Item attributes
if (params.info.hasOwnProperty('action'))
this.action_name = params.info.action.split('.')[1];
if (params.info.hasOwnProperty('target'))
this.action_target = params.info.target;
if (params.info.hasOwnProperty('label')) {
this.tooltip = new Tooltip({
parent: this,
markup: params.info.label,
});
this.accessible_name = params.info.label;
}
if (params.info.hasOwnProperty('icon'))
this.child = new St.Icon({gicon: params.info.icon});
// Submenu
for (const link of params.info.links) {
if (link.name === 'submenu') {
this.add_accessible_state(Atk.StateType.EXPANDABLE);
this.toggle_mode = true;
this.connect('notify::checked', this._onChecked);
this.submenu = new ListBox({
model: link.value,
action_group: this.action_group,
_parent: this._parent,
});
this.submenu.actor.style_class = 'popup-sub-menu';
this.submenu.actor.visible = false;
}
}
}
// This is (reliably?) emitted before ::clicked
_onChecked(button) {
if (button.checked) {
button.add_accessible_state(Atk.StateType.EXPANDED);
button.add_style_pseudo_class('active');
} else {
button.remove_accessible_state(Atk.StateType.EXPANDED);
button.remove_style_pseudo_class('active');
}
}
// This is (reliably?) emitted after notify::checked
vfunc_clicked(clicked_button) {
// Unless this has a submenu, activate the action and close the menu
if (!this.toggle_mode) {
this._parent._getTopMenu().close();
this.action_group.activate_action(
this.action_name,
this.action_target
);
// StButton.checked has already been toggled so we're opening
} else if (this.checked) {
this._parent.submenu = this.submenu;
// If this is the active submenu being closed, animate-close it
} else if (this._parent.submenu === this.submenu) {
this._parent.submenu = null;
}
}
});
export class IconBox extends PopupMenu.PopupMenuSection {
constructor(params) {
super();
Object.assign(this, params);
// Main Actor
this.actor = new St.BoxLayout({
vertical: true,
x_expand: true,
});
this.actor._delegate = this;
// Button Box
this.box._delegate = this;
this.box.style_class = 'gsconnect-icon-box';
this.box.vertical = false;
this.actor.add_child(this.box);
// Submenu Container
this.sub = new St.BoxLayout({
clip_to_allocation: true,
vertical: true,
x_expand: true,
});
this.sub.connect('transitions-completed', this._onTransitionsCompleted);
this.sub._delegate = this;
this.actor.add_child(this.sub);
// Track menu items so we can use ::items-changed
this._menu_items = new Map();
// PopupMenu
this._mappedId = this.actor.connect(
'notify::mapped',
this._onMapped.bind(this)
);
// GMenu
this._itemsChangedId = this.model.connect(
'items-changed',
this._onItemsChanged.bind(this)
);
// GActions
this._actionAddedId = this.action_group.connect(
'action-added',
this._onActionChanged.bind(this)
);
this._actionEnabledChangedId = this.action_group.connect(
'action-enabled-changed',
this._onActionChanged.bind(this)
);
this._actionRemovedId = this.action_group.connect(
'action-removed',
this._onActionChanged.bind(this)
);
}
destroy() {
this.actor.disconnect(this._mappedId);
this.model.disconnect(this._itemsChangedId);
this.action_group.disconnect(this._actionAddedId);
this.action_group.disconnect(this._actionEnabledChangedId);
this.action_group.disconnect(this._actionRemovedId);
super.destroy();
}
get submenu() {
return this._submenu || null;
}
set submenu(submenu) {
if (submenu) {
for (const button of this.box.get_children()) {
if (button.submenu && this._submenu && button.submenu !== submenu) {
button.checked = false;
button.submenu.actor.hide();
}
}
this.sub.set_height(0);
submenu.actor.show();
}
this.sub.save_easing_state();
this.sub.set_easing_duration(250);
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
this.sub.set_height(submenu ? submenu.actor.get_preferred_size()[1] : 0);
this.sub.restore_easing_state();
this._submenu = submenu;
}
_onMapped(actor) {
if (!actor.mapped) {
this._submenu = null;
for (const button of this.box.get_children())
button.checked = false;
for (const submenu of this.sub.get_children())
submenu.hide();
}
}
_onActionChanged(group, name, enabled) {
const menuItem = this._menu_items.get(name);
if (menuItem !== undefined)
menuItem.visible = group.get_action_enabled(name);
}
_onItemsChanged(model, position, removed, added) {
// Remove items
while (removed > 0) {
const button = this.box.get_child_at_index(position);
const action_name = button.action_name;
if (button.submenu)
button.submenu.destroy();
button.destroy();
this._menu_items.delete(action_name);
removed--;
}
// Add items
for (let i = 0; i < added; i++) {
const index = position + i;
// Create an iconic button
const button = new IconButton({
action_group: this.action_group,
info: getItemInfo(model, index),
// NOTE: Because this doesn't derive from a PopupMenu class
// it lacks some things its parent will expect from it
_parent: this,
_delegate: null,
});
// Set the visibility based on the enabled state
if (button.action_name !== undefined) {
button.visible = this.action_group.get_action_enabled(
button.action_name
);
}
// If it has a submenu, add it as a sibling
if (button.submenu)
this.sub.add_child(button.submenu.actor);
// Track the item if it has an action
if (button.action_name !== undefined)
this._menu_items.set(button.action_name, button);
// Insert it in the box at the defined position
this.box.insert_child_at_index(button, index);
}
}
_onTransitionsCompleted(actor) {
const menu = actor._delegate;
for (const button of menu.box.get_children()) {
if (button.submenu && button.submenu !== menu.submenu) {
button.checked = false;
button.submenu.actor.hide();
}
}
menu.sub.set_height(-1);
}
// PopupMenu.PopupMenuBase overrides
isEmpty() {
return (this.box.get_children().length === 0);
}
_setParent(parent) {
super._setParent(parent);
this._onItemsChanged(this.model, 0, 0, this.model.get_n_items());
}
}

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import Config from '../config.js';
export class LockscreenRemoteAccess {
constructor() {
this._inhibitor = null;
this._settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect',
null
),
path: '/org/gnome/shell/extensions/gsconnect/',
});
}
patchInhibitor() {
if (this._inhibitor)
return;
if (this._settings.get_boolean('keep-alive-when-locked')) {
this._inhibitor = global.backend.get_remote_access_controller().inhibit_remote_access;
global.backend.get_remote_access_controller().inhibit_remote_access = () => {};
}
}
unpatchInhibitor() {
if (!this._inhibitor)
return;
global.backend.get_remote_access_controller().inhibit_remote_access = this._inhibitor;
this._inhibitor = null;
}
}

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
/**
* Keybindings.Manager is a simple convenience class for managing keyboard
* shortcuts in GNOME Shell. You bind a shortcut using add(), which on success
* will return a non-zero action id that can later be used with remove() to
* unbind the shortcut.
*
* Accelerators are accepted in the form returned by Gtk.accelerator_name() and
* callbacks are invoked directly, so should be complete closures.
*
* References:
* https://developer.gnome.org/gtk3/stable/gtk3-Keyboard-Accelerators.html
* https://developer.gnome.org/meta/stable/MetaDisplay.html
* https://developer.gnome.org/meta/stable/meta-MetaKeybinding.html
* https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/windowManager.js#L1093-1112
*/
export class Manager {
constructor() {
this._keybindings = new Map();
this._acceleratorActivatedId = global.display.connect(
'accelerator-activated',
this._onAcceleratorActivated.bind(this)
);
}
_onAcceleratorActivated(display, action, inputDevice, timestamp) {
try {
const binding = this._keybindings.get(action);
if (binding !== undefined)
binding.callback();
} catch (e) {
logError(e);
}
}
/**
* Add a keybinding with callback
*
* @param {string} accelerator - An accelerator in the form '<Control>q'
* @param {Function} callback - A callback for the accelerator
* @return {number} A non-zero action id on success, or 0 on failure
*/
add(accelerator, callback) {
try {
const action = global.display.grab_accelerator(accelerator, 0);
if (action === Meta.KeyBindingAction.NONE)
throw new Error(`Failed to add keybinding: '${accelerator}'`);
const name = Meta.external_binding_name_for_action(action);
Main.wm.allowKeybinding(name, Shell.ActionMode.ALL);
this._keybindings.set(action, {name: name, callback: callback});
return action;
} catch (e) {
logError(e);
}
}
/**
* Remove a keybinding
*
* @param {number} action - A non-zero action id returned by add()
*/
remove(action) {
try {
const binding = this._keybindings.get(action);
global.display.ungrab_accelerator(action);
Main.wm.allowKeybinding(binding.name, Shell.ActionMode.NONE);
this._keybindings.delete(action);
} catch (e) {
logError(new Error(`Failed to remove keybinding: ${e.message}`));
}
}
/**
* Remove all keybindings
*/
removeAll() {
for (const action of this._keybindings.keys())
this.remove(action);
}
/**
* Destroy the keybinding manager and remove all keybindings
*/
destroy() {
global.display.disconnect(this._acceleratorActivatedId);
this.removeAll();
}
}

View File

@@ -0,0 +1,453 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js';
import * as NotificationDaemon from 'resource:///org/gnome/shell/ui/notificationDaemon.js';
import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
import {getIcon} from './utils.js';
const APP_ID = 'org.gnome.Shell.Extensions.GSConnect';
const APP_PATH = '/org/gnome/Shell/Extensions/GSConnect';
// deviceId Pattern (<device-id>|<remote-id>)
const DEVICE_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)$/);
// requestReplyId Pattern (<device-id>|<remote-id>)|<reply-id>)
const REPLY_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)\|([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/, 'i');
/**
* Extracted from notificationDaemon.js, as it's no longer exported
* https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/notificationDaemon.js#L556
* @returns {{ 'desktop-startup-id': string }} Object with ID containing current time
*/
function getPlatformData() {
const startupId = GLib.Variant.new('s', `_TIME${global.get_current_time()}`);
return {'desktop-startup-id': startupId};
}
// This is no longer directly exported, so we do this instead for now
const GtkNotificationDaemon = Main.notificationDaemon._gtkNotificationDaemon.constructor;
/**
* A slightly modified Notification Banner with an entry field
*/
const NotificationBanner = GObject.registerClass({
GTypeName: 'GSConnectNotificationBanner',
}, class NotificationBanner extends Calendar.NotificationMessage {
constructor(notification) {
super(notification);
if (notification.requestReplyId !== undefined)
this._addReplyAction();
}
_addReplyAction() {
if (!this._buttonBox) {
this._buttonBox = new St.BoxLayout({
style_class: 'notification-buttons-bin',
x_expand: true,
});
this.setActionArea(this._buttonBox);
global.focus_manager.add_group(this._buttonBox);
}
// Reply Button
const button = new St.Button({
style_class: 'notification-button',
label: _('Reply'),
x_expand: true,
can_focus: true,
});
button.connect(
'clicked',
this._onEntryRequested.bind(this)
);
this._buttonBox.add_child(button);
// Reply Entry
this._replyEntry = new St.Entry({
can_focus: true,
hint_text: _('Type a message'),
style_class: 'chat-response',
x_expand: true,
visible: false,
});
this._buttonBox.add_child(this._replyEntry);
}
_onEntryRequested(button) {
this.focused = true;
for (const child of this._buttonBox.get_children())
child.visible = (child === this._replyEntry);
// Release the notification focus with the entry focus
this._replyEntry.connect(
'key-focus-out',
this._onEntryDismissed.bind(this)
);
this._replyEntry.clutter_text.connect(
'activate',
this._onEntryActivated.bind(this)
);
this._replyEntry.grab_key_focus();
}
_onEntryDismissed(entry) {
this.focused = false;
this.emit('unfocused');
}
_onEntryActivated(clutter_text) {
// Refuse to send empty replies
if (clutter_text.text === '')
return;
// Copy the text, then clear the entry
const text = clutter_text.text;
clutter_text.text = '';
const {deviceId, requestReplyId} = this.notification;
const target = new GLib.Variant('(ssbv)', [
deviceId,
'replyNotification',
true,
new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}]),
]);
const platformData = getPlatformData();
Gio.DBus.session.call(
APP_ID,
APP_PATH,
'org.freedesktop.Application',
'ActivateAction',
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
null,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
null,
(connection, res) => {
try {
connection.call_finish(res);
} catch (e) {
// Silence errors
}
}
);
this.close();
}
});
/**
* A custom notification source for spawning notifications and closing device
* notifications. This source isn't actually used, but it's methods are patched
* into existing sources.
*/
const Source = GObject.registerClass({
GTypeName: 'GSConnectNotificationSource',
}, class Source extends NotificationDaemon.GtkNotificationDaemonAppSource {
_closeGSConnectNotification(notification, reason) {
if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
return;
// Avoid sending the request multiple times
if (notification._remoteClosed || notification.remoteId === undefined)
return;
notification._remoteClosed = true;
const target = new GLib.Variant('(ssbv)', [
notification.deviceId,
'closeNotification',
true,
new GLib.Variant('s', notification.remoteId),
]);
const platformData = getPlatformData();
Gio.DBus.session.call(
APP_ID,
APP_PATH,
'org.freedesktop.Application',
'ActivateAction',
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
null,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
null,
(connection, res) => {
try {
connection.call_finish(res);
} catch (e) {
// If we fail, reset in case we can try again
notification._remoteClosed = false;
}
}
);
}
/*
* Parse the id to determine if it's a repliable notification, device
* notification or a regular local notification
*/
_parseNotificationId(notificationId) {
let idMatch, deviceId, requestReplyId, remoteId, localId;
if ((idMatch = REPLY_REGEX.exec(notificationId))) {
[, deviceId, remoteId, requestReplyId] = idMatch;
localId = `${deviceId}|${remoteId}`;
} else if ((idMatch = DEVICE_REGEX.exec(notificationId))) {
[, deviceId, remoteId] = idMatch;
localId = `${deviceId}|${remoteId}`;
} else {
localId = notificationId;
}
return [idMatch, deviceId, requestReplyId, remoteId, localId];
}
/*
* Add notification to source or update existing notification with extra
* GsConnect information
*/
_createNotification(notification) {
const [idMatch, deviceId, requestReplyId, remoteId, localId] = this._parseNotificationId(notification.id);
const cachedNotification = this._notifications[localId];
// Check if this is a repeat
if (cachedNotification) {
cachedNotification.requestReplyId = requestReplyId;
// Bail early If @notificationParams represents an exact repeat
const title = notification.title;
const body = notification.body
? notification.body
: null;
if (cachedNotification.title === title &&
cachedNotification.body === body)
return cachedNotification;
cachedNotification.title = title;
cachedNotification.body = body;
return cachedNotification;
}
// Device Notification
if (idMatch) {
notification.deviceId = deviceId;
notification.remoteId = remoteId;
notification.requestReplyId = requestReplyId;
notification.connect('destroy', (notification, reason) => {
this._closeGSConnectNotification(notification, reason);
delete this._notifications[localId];
});
// Service Notification
} else {
notification.connect('destroy', (notification, reason) => {
delete this._notifications[localId];
});
}
this._notifications[localId] = notification;
return notification;
}
/*
* Override to control notification spawning
*/
addNotification(notification) {
this._notificationPending = true;
// Fix themed icons
if (notification.icon) {
let gicon = notification.icon;
if (gicon instanceof Gio.ThemedIcon) {
gicon = getIcon(gicon.names[0]);
notification.icon = gicon.serialize();
}
}
const createdNotification = this._createNotification(notification);
this._addNotificationToMessageTray(createdNotification);
this._notificationPending = false;
}
/*
* Reimplementation of MessageTray.addNotification to raise the usual
* notification limit (3)
*/
_addNotificationToMessageTray(notification) {
if (this.notifications.includes(notification)) {
notification.acknowledged = false;
return;
}
while (this.notifications.length >= 10) {
const [oldest] = this.notifications;
oldest.destroy(MessageTray.NotificationDestroyedReason.EXPIRED);
}
notification.connect('destroy', this._onNotificationDestroy.bind(this));
notification.connect('notify::acknowledged', () => {
this.countUpdated();
// If acknowledged was set to false try to show the notification again
if (!notification.acknowledged)
this.emit('notification-request-banner', notification);
});
this.notifications.push(notification);
this.emit('notification-added', notification);
this.emit('notification-request-banner', notification);
}
createBanner(notification) {
return new NotificationBanner(notification);
}
});
/**
* If there is an active GtkNotificationDaemonAppSource for GSConnect when the
* extension is loaded, it has to be patched in place.
*/
export function patchGSConnectNotificationSource() {
const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID];
if (source !== undefined) {
// Patch in the subclassed methods
source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
source._parseNotificationId = Source.prototype._parseNotificationId;
source._createNotification = Source.prototype._createNotification;
source.addNotification = Source.prototype.addNotification;
source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
source.createBanner = Source.prototype.createBanner;
// Connect to existing notifications
for (const notification of Object.values(source._notifications)) {
const _id = notification.connect('destroy', (notification, reason) => {
source._closeGSConnectNotification(notification, reason);
notification.disconnect(_id);
});
}
}
}
/**
* Wrap GtkNotificationDaemon._ensureAppSource() to patch GSConnect's app source
* https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/notificationDaemon.js#L742-755
*/
const __ensureAppSource = GtkNotificationDaemon.prototype._ensureAppSource;
// eslint-disable-next-line func-style
const _ensureAppSource = function (appId) {
const source = __ensureAppSource.call(this, appId);
if (source._appId === APP_ID) {
source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
source._parseNotificationId = Source.prototype._parseNotificationId;
source._createNotification = Source.prototype._createNotification;
source.addNotification = Source.prototype.addNotification;
source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
source.createBanner = Source.prototype.createBanner;
}
return source;
};
export function patchGtkNotificationDaemon() {
GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource;
}
export function unpatchGtkNotificationDaemon() {
GtkNotificationDaemon.prototype._ensureAppSource = __ensureAppSource;
}
/**
* We patch other Gtk notification sources so we can notify remote devices when
* notifications have been closed locally.
*/
const _addNotification = NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification;
export function patchGtkNotificationSources() {
// eslint-disable-next-line func-style
const _withdrawGSConnectNotification = function (id, notification, reason) {
if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
return;
// Avoid sending the request multiple times
if (notification._remoteWithdrawn)
return;
notification._remoteWithdrawn = true;
// Recreate the notification id as it would've been sent
const target = new GLib.Variant('(ssbv)', [
'*',
'withdrawNotification',
true,
new GLib.Variant('s', `gtk|${this._appId}|${id}`),
]);
const platformData = getPlatformData();
Gio.DBus.session.call(
APP_ID,
APP_PATH,
'org.freedesktop.Application',
'ActivateAction',
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
null,
Gio.DBusCallFlags.NO_AUTO_START,
-1,
null,
(connection, res) => {
try {
connection.call_finish(res);
} catch (e) {
// If we fail, reset in case we can try again
notification._remoteWithdrawn = false;
}
}
);
};
NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification;
}
export function unpatchGtkNotificationSources() {
NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification;
delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification;
}

View File

@@ -0,0 +1,309 @@
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
/**
* An StTooltip for ClutterActors
*
* Adapted from: https://github.com/RaphaelRochet/applications-overview-tooltip
* See also: https://github.com/GNOME/gtk/blob/master/gtk/gtktooltip.c
*/
export let TOOLTIP_BROWSE_ID = 0;
export let TOOLTIP_BROWSE_MODE = false;
export default class Tooltip {
constructor(params) {
Object.assign(this, params);
this._bin = null;
this._hoverTimeoutId = 0;
this._showing = false;
this._destroyId = this.parent.connect(
'destroy',
this.destroy.bind(this)
);
this._hoverId = this.parent.connect(
'notify::hover',
this._onHover.bind(this)
);
this._buttonPressEventId = this.parent.connect(
'button-press-event',
this._hide.bind(this)
);
}
get custom() {
if (this._custom === undefined)
this._custom = null;
return this._custom;
}
set custom(actor) {
this._custom = actor;
this._markup = null;
this._text = null;
if (this._showing)
this._show();
}
get gicon() {
if (this._gicon === undefined)
this._gicon = null;
return this._gicon;
}
set gicon(gicon) {
this._gicon = gicon;
if (this._showing)
this._show();
}
get icon() {
return (this.gicon) ? this.gicon.name : null;
}
set icon(icon_name) {
if (!icon_name)
this.gicon = null;
else
this.gicon = new Gio.ThemedIcon({name: icon_name});
}
get markup() {
if (this._markup === undefined)
this._markup = null;
return this._markup;
}
set markup(text) {
this._markup = text;
this._text = null;
if (this._showing)
this._show();
}
get text() {
if (this._text === undefined)
this._text = null;
return this._text;
}
set text(text) {
this._markup = null;
this._text = text;
if (this._showing)
this._show();
}
get x_offset() {
if (this._x_offset === undefined)
this._x_offset = 0;
return this._x_offset;
}
set x_offset(offset) {
this._x_offset = (Number.isInteger(offset)) ? offset : 0;
}
get y_offset() {
if (this._y_offset === undefined)
this._y_offset = 0;
return this._y_offset;
}
set y_offset(offset) {
this._y_offset = (Number.isInteger(offset)) ? offset : 0;
}
_show() {
if (this.text === null && this.markup === null)
return this._hide();
if (this._bin === null) {
this._bin = new St.Bin({
style_class: 'osd-window gsconnect-tooltip',
opacity: 232,
});
if (this.custom) {
this._bin.child = this.custom;
} else {
this._bin.child = new St.BoxLayout({vertical: false});
if (this.gicon) {
this._bin.child.icon = new St.Icon({
gicon: this.gicon,
y_align: St.Align.START,
});
this._bin.child.icon.set_y_align(Clutter.ActorAlign.START);
this._bin.child.add_child(this._bin.child.icon);
}
this.label = new St.Label({text: this.markup || this.text});
this.label.clutter_text.line_wrap = true;
this.label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD;
this.label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this.label.clutter_text.use_markup = (this.markup);
this._bin.child.add_child(this.label);
}
Main.layoutManager.uiGroup.add_child(this._bin);
Main.layoutManager.uiGroup.set_child_above_sibling(this._bin, null);
} else if (this.custom) {
this._bin.child = this.custom;
} else {
if (this._bin.child.icon)
this._bin.child.icon.destroy();
if (this.gicon) {
this._bin.child.icon = new St.Icon({gicon: this.gicon});
this._bin.child.insert_child_at_index(this._bin.child.icon, 0);
}
this.label.clutter_text.text = this.markup || this.text;
this.label.clutter_text.use_markup = (this.markup);
}
// Position tooltip
let [x, y] = this.parent.get_transformed_position();
x = (x + (this.parent.width / 2)) - Math.round(this._bin.width / 2);
x += this.x_offset;
y += this.y_offset;
// Show tooltip
if (this._showing) {
this._bin.ease({
x: x,
y: y,
time: 0.15,
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
});
} else {
this._bin.set_position(x, y);
this._bin.ease({
opacity: 232,
time: 0.15,
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
});
this._showing = true;
}
// Enable browse mode
TOOLTIP_BROWSE_MODE = true;
if (TOOLTIP_BROWSE_ID) {
GLib.source_remove(TOOLTIP_BROWSE_ID);
TOOLTIP_BROWSE_ID = 0;
}
if (this._hoverTimeoutId) {
GLib.source_remove(this._hoverTimeoutId);
this._hoverTimeoutId = 0;
}
}
_hide() {
if (this._bin) {
this._bin.ease({
opacity: 0,
time: 0.10,
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
Main.layoutManager.uiGroup.remove_actor(this._bin);
if (this.custom)
this._bin.remove_child(this.custom);
this._bin.destroy();
this._bin = null;
},
});
}
TOOLTIP_BROWSE_ID = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
TOOLTIP_BROWSE_MODE = false;
TOOLTIP_BROWSE_ID = 0;
return false;
});
if (this._hoverTimeoutId) {
GLib.source_remove(this._hoverTimeoutId);
this._hoverTimeoutId = 0;
}
this._showing = false;
this._hoverTimeoutId = 0;
}
_onHover() {
if (this.parent.hover) {
if (!this._hoverTimeoutId) {
if (this._showing) {
this._show();
} else {
this._hoverTimeoutId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
(TOOLTIP_BROWSE_MODE) ? 60 : 500,
() => {
this._show();
this._hoverTimeoutId = 0;
return false;
}
);
}
}
} else {
this._hide();
}
}
destroy() {
this.parent.disconnect(this._destroyId);
this.parent.disconnect(this._hoverId);
this.parent.disconnect(this._buttonPressEventId);
if (this.custom)
this.custom.destroy();
if (this._bin) {
Main.layoutManager.uiGroup.remove_actor(this._bin);
this._bin.destroy();
}
if (TOOLTIP_BROWSE_ID) {
GLib.source_remove(TOOLTIP_BROWSE_ID);
TOOLTIP_BROWSE_ID = 0;
}
if (this._hoverTimeoutId) {
GLib.source_remove(this._hoverTimeoutId);
this._hoverTimeoutId = 0;
}
}
}

Some files were not shown because too many files have changed in this diff Show More