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