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

View File

@ -0,0 +1,283 @@
// 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 Gtk from 'gi://Gtk';
import Config from '../config.js';
let St = null; // St is not available for prefs.js importing this file.
try {
St = (await import('gi://St')).default;
} catch (e) { }
/**
* Get a themed icon, using fallbacks from GSConnect's GResource when necessary.
*
* @param {string} name - A themed icon name
* @return {Gio.Icon} A themed icon
*/
export function getIcon(name) {
if (getIcon._resource === undefined) {
// Setup the desktop icons
const settings = St.Settings.get();
getIcon._desktop = new Gtk.IconTheme();
getIcon._desktop.set_theme_name(settings.gtk_icon_theme);
settings.connect('notify::gtk-icon-theme', (settings_, key_) => {
getIcon._desktop.set_theme_name(settings_.gtk_icon_theme);
});
// Preload our fallbacks
const iconPath = 'resource://org/gnome/Shell/Extensions/GSConnect/icons';
const iconNames = [
'org.gnome.Shell.Extensions.GSConnect',
'org.gnome.Shell.Extensions.GSConnect-symbolic',
'computer-symbolic',
'laptop-symbolic',
'smartphone-symbolic',
'tablet-symbolic',
'tv-symbolic',
'phonelink-ring-symbolic',
'sms-symbolic',
];
getIcon._resource = {};
for (const iconName of iconNames) {
getIcon._resource[iconName] = new Gio.FileIcon({
file: Gio.File.new_for_uri(`${iconPath}/${iconName}.svg`),
});
}
}
// Check the desktop icon theme
if (getIcon._desktop.has_icon(name))
return new Gio.ThemedIcon({name: name});
// Check our GResource
if (getIcon._resource[name] !== undefined)
return getIcon._resource[name];
// Fallback to hoping it's in the theme somewhere
return new Gio.ThemedIcon({name: name});
}
/**
* Get the contents of a GResource file, replacing `@PACKAGE_DATADIR@` where
* necessary.
*
* @param {string} relativePath - A path relative to GSConnect's resource path
* @return {string} The file contents as a string
*/
function getResource(relativePath) {
try {
const bytes = Gio.resources_lookup_data(
GLib.build_filenamev([Config.APP_PATH, relativePath]),
Gio.ResourceLookupFlags.NONE
);
const source = new TextDecoder().decode(bytes.toArray());
return source.replace('@PACKAGE_DATADIR@', Config.PACKAGE_DATADIR);
} catch (e) {
logError(e, 'GSConnect');
return null;
}
}
/**
* Install file contents, to an absolute directory path.
*
* @param {string} dirname - An absolute directory path
* @param {string} basename - The file name
* @param {string} contents - The file contents
* @return {boolean} A success boolean
*/
function _installFile(dirname, basename, contents) {
try {
const filename = GLib.build_filenamev([dirname, basename]);
GLib.mkdir_with_parents(dirname, 0o755);
return GLib.file_set_contents(filename, contents);
} catch (e) {
logError(e, 'GSConnect');
return false;
}
}
/**
* Install file contents from a GResource, to an absolute directory path.
*
* @param {string} dirname - An absolute directory path
* @param {string} basename - The file name
* @param {string} relativePath - A path relative to GSConnect's resource path
* @return {boolean} A success boolean
*/
function _installResource(dirname, basename, relativePath) {
try {
const contents = getResource(relativePath);
return _installFile(dirname, basename, contents);
} catch (e) {
logError(e, 'GSConnect');
return false;
}
}
/**
* Use Gio.File to ensure a file's executable bits are set.
*
* @param {string} filepath - An absolute path to a file
* @returns {boolean} - True if the file already was, or is now, executable
*/
function _setExecutable(filepath) {
try {
const file = Gio.File.new_for_path(filepath);
const finfo = file.query_info(
`${Gio.FILE_ATTRIBUTE_STANDARD_TYPE},${Gio.FILE_ATTRIBUTE_UNIX_MODE}`,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null);
if (!finfo.has_attribute(Gio.FILE_ATTRIBUTE_UNIX_MODE))
return false;
const mode = finfo.get_attribute_uint32(
Gio.FILE_ATTRIBUTE_UNIX_MODE);
const new_mode = (mode | 0o111);
if (mode === new_mode)
return true;
return file.set_attribute_uint32(
Gio.FILE_ATTRIBUTE_UNIX_MODE,
new_mode,
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
null);
} catch (e) {
logError(e, 'GSConnect');
return false;
}
}
/**
* Ensure critical files in the extension directory have the
* correct permissions.
*/
export function ensurePermissions() {
if (Config.IS_USER) {
const executableFiles = [
'gsconnect-preferences',
'service/daemon.js',
'service/nativeMessagingHost.js',
];
for (const file of executableFiles)
_setExecutable(GLib.build_filenamev([Config.PACKAGE_DATADIR, file]));
}
}
/**
* Install the files necessary for the GSConnect service to run.
*/
export function installService() {
const settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect',
null
),
path: '/org/gnome/shell/extensions/gsconnect/',
});
const confDir = GLib.get_user_config_dir();
const dataDir = GLib.get_user_data_dir();
const homeDir = GLib.get_home_dir();
// DBus Service
const dbusDir = GLib.build_filenamev([dataDir, 'dbus-1', 'services']);
const dbusFile = `${Config.APP_ID}.service`;
// Desktop Entry
const appDir = GLib.build_filenamev([dataDir, 'applications']);
const appFile = `${Config.APP_ID}.desktop`;
const appPrefsFile = `${Config.APP_ID}.Preferences.desktop`;
// Application Icon
const iconDir = GLib.build_filenamev([dataDir, 'icons', 'hicolor', 'scalable', 'apps']);
const iconFull = `${Config.APP_ID}.svg`;
const iconSym = `${Config.APP_ID}-symbolic.svg`;
// File Manager Extensions
const fileManagers = [
[`${dataDir}/nautilus-python/extensions`, 'nautilus-gsconnect.py'],
[`${dataDir}/nemo-python/extensions`, 'nemo-gsconnect.py'],
];
// WebExtension Manifests
const manifestFile = 'org.gnome.shell.extensions.gsconnect.json';
const google = getResource(`webextension/${manifestFile}.google.in`);
const mozilla = getResource(`webextension/${manifestFile}.mozilla.in`);
const manifests = [
[`${confDir}/chromium/NativeMessagingHosts/`, google],
[`${confDir}/google-chrome/NativeMessagingHosts/`, google],
[`${confDir}/google-chrome-beta/NativeMessagingHosts/`, google],
[`${confDir}/google-chrome-unstable/NativeMessagingHosts/`, google],
[`${confDir}/BraveSoftware/Brave-Browser/NativeMessagingHosts/`, google],
[`${confDir}/BraveSoftware/Brave-Browser-Beta/NativeMessagingHosts/`, google],
[`${confDir}/BraveSoftware/Brave-Browser-Nightly/NativeMessagingHosts/`, google],
[`${homeDir}/.mozilla/native-messaging-hosts/`, mozilla],
[`${homeDir}/.config/microsoft-edge-dev/NativeMessagingHosts`, google],
[`${homeDir}/.config/microsoft-edge-beta/NativeMessagingHosts`, google],
];
// If running as a user extension, ensure the DBus service, desktop entry,
// file manager scripts, and WebExtension manifests are installed.
if (Config.IS_USER) {
// DBus Service
if (!_installResource(dbusDir, dbusFile, `${dbusFile}.in`))
throw Error('GSConnect: Failed to install DBus Service');
// Desktop Entries
_installResource(appDir, appFile, appFile);
_installResource(appDir, appPrefsFile, appPrefsFile);
// Application Icon
_installResource(iconDir, iconFull, `icons/${iconFull}`);
_installResource(iconDir, iconSym, `icons/${iconSym}`);
// File Manager Extensions
const target = `${Config.PACKAGE_DATADIR}/nautilus-gsconnect.py`;
for (const [dir, name] of fileManagers) {
const script = Gio.File.new_for_path(GLib.build_filenamev([dir, name]));
if (!script.query_exists(null)) {
GLib.mkdir_with_parents(dir, 0o755);
script.make_symbolic_link(target, null);
}
}
// WebExtension Manifests
if (settings.get_boolean('create-native-messaging-hosts')) {
for (const [dirname, contents] of manifests)
_installFile(dirname, manifestFile, contents);
}
// Otherwise, if running as a system extension, ensure anything previously
// installed when running as a user extension is removed.
} else {
GLib.unlink(GLib.build_filenamev([dbusDir, dbusFile]));
GLib.unlink(GLib.build_filenamev([appDir, appFile]));
GLib.unlink(GLib.build_filenamev([appDir, appPrefsFile]));
GLib.unlink(GLib.build_filenamev([iconDir, iconFull]));
GLib.unlink(GLib.build_filenamev([iconDir, iconSym]));
for (const [dir, name] of fileManagers)
GLib.unlink(GLib.build_filenamev([dir, name]));
for (const manifest of manifests)
GLib.unlink(GLib.build_filenamev([manifest[0], manifestFile]));
}
}