initial commit

This commit is contained in:
2022-01-12 14:55:33 -03:00
commit c968bf909f
330 changed files with 61257 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
'use strict';
const Gettext = imports.gettext;
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;
const Gtk = imports.gi.Gtk;
const Extension = imports.misc.extensionUtils.getCurrentExtension();
const Config = Extension.imports.config;
Config.PACKAGE_DATADIR = Extension.path;
// Ensure config.js is setup properly
const userDir = GLib.build_filenamev([GLib.get_user_data_dir(), 'gnome-shell']);
if (Config.PACKAGE_DATADIR.startsWith(userDir)) {
Config.IS_USER = true;
Config.GSETTINGS_SCHEMA_DIR = `${Extension.path}/schemas`;
Config.PACKAGE_LOCALEDIR = `${Extension.path}/locale`;
}
// Init Gettext
Gettext.bindtextdomain(Config.APP_ID, Config.PACKAGE_LOCALEDIR);
Extension._ = GLib.dgettext.bind(null, Config.APP_ID);
Extension.ngettext = GLib.dngettext.bind(null, Config.APP_ID);
// Init GResources
Gio.Resource.load(
GLib.build_filenamev([Config.PACKAGE_DATADIR, `${Config.APP_ID}.gresource`])
)._register();
// Init GSchema
Config.GSCHEMA = Gio.SettingsSchemaSource.new_from_directory(
Config.GSETTINGS_SCHEMA_DIR,
Gio.SettingsSchemaSource.get_default(),
false
);

View File

@@ -0,0 +1,380 @@
'use strict';
const ByteArray = imports.byteArray;
const Gio = imports.gi.Gio;
const GjsPrivate = imports.gi.GjsPrivate;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Meta = imports.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.
*/
var 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 = mimetypes.find(type => TEXT_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(ByteArray.toString(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();
}
}
});
var _portal = null;
var _portalId = 0;
/**
* Watch for the service to start and export the clipboard portal when it does.
*/
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.
*/
function unwatchService() {
if (_portalId > 0) {
Gio.bus_unwatch_name(_portalId);
_portalId = 0;
}
}

View File

@@ -0,0 +1,379 @@
'use strict';
const Clutter = imports.gi.Clutter;
const GObject = imports.gi.GObject;
const St = imports.gi.St;
const PanelMenu = imports.ui.panelMenu;
const PopupMenu = imports.ui.popupMenu;
const Extension = imports.misc.extensionUtils.getCurrentExtension();
// eslint-disable-next-line no-redeclare
const _ = Extension._;
const GMenu = Extension.imports.shell.gmenu;
const Tooltip = Extension.imports.shell.tooltip;
/**
* A battery widget with an icon, text percentage and time estimate tooltip
*/
var 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.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
*/
var 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.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
*/
var Menu = 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
*/
var 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: Extension.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,649 @@
'use strict';
const Atk = imports.gi.Atk;
const Clutter = imports.gi.Clutter;
const Gio = imports.gi.Gio;
const GObject = imports.gi.GObject;
const St = imports.gi.St;
const PopupMenu = imports.ui.popupMenu;
const Extension = imports.misc.extensionUtils.getCurrentExtension();
const Tooltip = Extension.imports.shell.tooltip;
/**
* 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 = Extension.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;
}
/**
*
*/
var ListBox = 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
);
}
// Modify the ::activate callback to invoke the GAction or submenu
item.disconnect(item._activateId);
item._activateId = item.connect(
'activate',
this._onGMenuItemActivate.bind(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._ornamentLabel, prevArrow);
this.addMenuItem(prev, 0);
// Modify the ::activate callback to close the submenu
prev.disconnect(prev._activateId);
prev._activateId = prev.connect('activate', (item, event) => {
this.emit('activate', item);
this._parent.submenu = null;
});
}
}
_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
*/
var 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.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;
}
}
});
var IconBox = 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,102 @@
'use strict';
const Config = imports.misc.config;
const Main = imports.ui.main;
const Meta = imports.gi.Meta;
const Shell = imports.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
*/
var Manager = 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,439 @@
'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const St = imports.gi.St;
const Main = imports.ui.main;
const MessageTray = imports.ui.messageTray;
const NotificationDaemon = imports.ui.notificationDaemon;
const Extension = imports.misc.extensionUtils.getCurrentExtension();
// eslint-disable-next-line no-redeclare
const _ = Extension._;
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');
/**
* A slightly modified Notification Banner with an entry field
*/
const NotificationBanner = GObject.registerClass({
GTypeName: 'GSConnectNotificationBanner',
}, class NotificationBanner extends MessageTray.NotificationBanner {
_init(notification) {
super._init(notification);
if (notification.requestReplyId !== undefined)
this._addReplyAction();
}
_addReplyAction() {
if (!this._buttonBox) {
this._buttonBox = new St.BoxLayout({
style_class: 'notification-actions',
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 = NotificationDaemon.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 = NotificationDaemon.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;
}
}
);
}
/*
* Override to control notification spawning
*/
addNotification(notificationId, notificationParams, showBanner) {
this._notificationPending = true;
// Parse the id to determine if it's a repliable notification, device
// notification or a regular local notification
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;
}
// Fix themed icons
if (notificationParams.icon) {
let gicon = Gio.Icon.deserialize(notificationParams.icon);
if (gicon instanceof Gio.ThemedIcon) {
gicon = Extension.getIcon(gicon.names[0]);
notificationParams.icon = gicon.serialize();
}
}
let notification = this._notifications[localId];
// Check if this is a repeat
if (notification) {
notification.requestReplyId = requestReplyId;
// Bail early If @notificationParams represents an exact repeat
const title = notificationParams.title.unpack();
const body = notificationParams.body
? notificationParams.body.unpack()
: null;
if (notification.title === title &&
notification.bannerBodyText === body) {
this._notificationPending = false;
return;
}
notification.title = title;
notification.bannerBodyText = body;
// Device Notification
} else if (idMatch) {
notification = this._createNotification(notificationParams);
notification.deviceId = deviceId;
notification.remoteId = remoteId;
notification.requestReplyId = requestReplyId;
notification.connect('destroy', (notification, reason) => {
this._closeGSConnectNotification(notification, reason);
delete this._notifications[localId];
});
this._notifications[localId] = notification;
// Service Notification
} else {
notification = this._createNotification(notificationParams);
notification.connect('destroy', (notification, reason) => {
delete this._notifications[localId];
});
this._notifications[localId] = notification;
}
if (showBanner)
this.showNotification(notification);
else
this.pushNotification(notification);
this._notificationPending = false;
}
/*
* Override to raise the usual notification limit (3)
*/
pushNotification(notification) {
if (this.notifications.includes(notification))
return;
while (this.notifications.length >= 10)
this.notifications.shift().destroy(MessageTray.NotificationDestroyedReason.EXPIRED);
notification.connect('destroy', this._onNotificationDestroy.bind(this));
notification.connect('notify::acknowledged', this.countUpdated.bind(this));
this.notifications.push(notification);
this.emit('notification-added', notification);
this.countUpdated();
}
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.
*/
function patchGSConnectNotificationSource() {
const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID];
if (source !== undefined) {
// Patch in the subclassed methods
source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
source.addNotification = Source.prototype.addNotification;
source.pushNotification = Source.prototype.pushNotification;
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 = NotificationDaemon.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.addNotification = Source.prototype.addNotification;
source.pushNotification = Source.prototype.pushNotification;
source.createBanner = Source.prototype.createBanner;
}
return source;
};
function patchGtkNotificationDaemon() {
NotificationDaemon.GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource;
}
function unpatchGtkNotificationDaemon() {
NotificationDaemon.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;
function patchGtkNotificationSources() {
// This should diverge as little as possible from the original
// eslint-disable-next-line func-style
const addNotification = function (notificationId, notificationParams, showBanner) {
this._notificationPending = true;
if (this._notifications[notificationId])
this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED);
const notification = this._createNotification(notificationParams);
notification.connect('destroy', (notification, reason) => {
this._withdrawGSConnectNotification(notification, reason);
delete this._notifications[notificationId];
});
this._notifications[notificationId] = notification;
if (showBanner)
this.showNotification(notification);
else
this.pushNotification(notification);
this._notificationPending = false;
};
// 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 = NotificationDaemon.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.addNotification = addNotification;
NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification;
}
function unpatchGtkNotificationSources() {
NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification;
delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification;
}

View File

@@ -0,0 +1,302 @@
'use strict';
const Clutter = imports.gi.Clutter;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Pango = imports.gi.Pango;
const St = imports.gi.St;
const Main = imports.ui.main;
/**
* 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
*/
var TOOLTIP_BROWSE_ID = 0;
var TOOLTIP_BROWSE_MODE = false;
var Tooltip = 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;
},
});
}
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 (this._hoverTimeoutId) {
GLib.source_remove(this._hoverTimeoutId);
this._hoverTimeoutId = 0;
}
}
};

View File

@@ -0,0 +1,218 @@
'use strict';
const ByteArray = imports.byteArray;
const GLib = imports.gi.GLib;
const Gio = imports.gi.Gio;
const Extension = imports.misc.extensionUtils.getCurrentExtension();
const Config = Extension.imports.config;
/**
* 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
*/
function getIcon(name) {
if (getIcon._resource === undefined) {
// Setup the desktop icons
const settings = imports.gi.St.Settings.get();
getIcon._desktop = new imports.gi.Gtk.IconTheme();
getIcon._desktop.set_custom_theme(settings.gtk_icon_theme);
settings.connect('notify::gtk-icon-theme', (settings_, key_) => {
getIcon._desktop.set_custom_theme(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 = ByteArray.toString(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;
}
}
/**
* Install the files necessary for the GSConnect service to run.
*/
function installService() {
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],
[`${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
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]));
}
}