1
0
Fork 0

Adding upstream version 48.2.

Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
This commit is contained in:
Daniel Baumann 2025-06-22 20:26:11 +02:00
parent 0d8723e422
commit 1fcdbd5df9
Signed by: daniel.baumann
GPG key ID: BCC918A2ABD66424
1059 changed files with 623842 additions and 0 deletions

View file

@ -0,0 +1,9 @@
import {programInvocationName, programArgs} from 'system';
imports.package.init({
name: '@PACKAGE_NAME@',
prefix: '@prefix@',
libdir: '@libdir@',
});
const {main} = await import(`${imports.package.moduledir}/main.js`);
await main([programInvocationName, ...programArgs]);

View file

@ -0,0 +1,3 @@
[D-BUS Service]
Name=@service@
Exec=@gjs@ -m @pkgdatadir@/@service@

View file

@ -0,0 +1,201 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import {programArgs} from 'system';
import './misc/dbusErrors.js';
const Signals = imports.signals;
const IDLE_SHUTDOWN_TIME = 2; // s
export class ServiceImplementation {
constructor(info, objectPath) {
this._objectPath = objectPath;
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(info, this);
this._injectTracking('return_dbus_error');
this._injectTracking('return_error_literal');
this._injectTracking('return_gerror');
this._injectTracking('return_value');
this._injectTracking('return_value_with_unix_fd_list');
this._senders = new Map();
this._holdCount = 0;
// Bail out when not running under gnome-shell
Gio.DBus.watch_name(Gio.BusType.SESSION,
'org.gnome.Shell',
Gio.BusNameWatcherFlags.NONE,
(c, name, owner) => (this._shellName = owner),
() => {
this._shellName = null;
// For auto-shutdown services, delay shutting
// down in case the shell reappears
if (this._autoShutdown)
this._queueShutdownCheck();
else
this.emit('shutdown');
});
this._hasSignals = this._dbusImpl.get_info().signals.length > 0;
this._shutdownTimeoutId = 0;
// subclasses may override this to disable automatic shutdown
this._autoShutdown = true;
this._queueShutdownCheck();
}
// subclasses may override this to own additional names
register() {
}
export() {
this._dbusImpl.export(Gio.DBus.session, this._objectPath);
}
unexport() {
this._dbusImpl.unexport();
}
hold() {
this._holdCount++;
}
release() {
if (this._holdCount === 0) {
logError(new Error('Unmatched call to release()'));
return;
}
this._holdCount--;
if (this._holdCount === 0)
this._queueShutdownCheck();
}
/**
* Complete @invocation with an appropriate error if @error is set;
* useful for implementing early returns from method implementations.
*
* @param {Gio.DBusMethodInvocation}
* @param {Error}
*
* @returns {bool} - true if @invocation was completed
*/
_handleError(invocation, error) {
if (error === null)
return false;
if (error instanceof GLib.Error) {
Gio.DBusError.strip_remote_error(error);
invocation.return_gerror(error);
} else {
let name = error.name;
if (!name.includes('.')) // likely a normal JS error
name = `org.gnome.gjs.JSError.${name}`;
invocation.return_dbus_error(name, error.message);
}
return true;
}
_maybeShutdown() {
if (!this._autoShutdown)
return;
if (GLib.getenv('SHELL_DBUS_PERSIST'))
return;
if (this._shellName && this._holdCount > 0)
return;
this.emit('shutdown');
}
_queueShutdownCheck() {
if (this._shutdownTimeoutId)
GLib.source_remove(this._shutdownTimeoutId);
this._shutdownTimeoutId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT, IDLE_SHUTDOWN_TIME,
() => {
this._shutdownTimeoutId = 0;
this._maybeShutdown();
return GLib.SOURCE_REMOVE;
});
}
_trackSender(sender) {
if (this._senders.has(sender))
return;
if (sender === this._shellName)
return; // don't track the shell
this.hold();
this._senders.set(sender,
this._dbusImpl.get_connection().watch_name(
sender,
Gio.BusNameWatcherFlags.NONE,
null,
() => this._untrackSender(sender)));
}
_untrackSender(sender) {
const id = this._senders.get(sender);
if (id)
this._dbusImpl.get_connection().unwatch_name(id);
if (this._senders.delete(sender))
this.release();
}
_injectTracking(methodName) {
const {prototype} = Gio.DBusMethodInvocation;
const origMethod = prototype[methodName];
const that = this;
prototype[methodName] = function (...args) {
origMethod.apply(this, args);
if (that._hasSignals)
that._trackSender(this.get_sender());
that._queueShutdownCheck();
};
}
}
Signals.addSignalMethods(ServiceImplementation.prototype);
export class DBusService {
constructor(name, service) {
this._name = name;
this._service = service;
this._loop = new GLib.MainLoop(null, false);
this._service.connect('shutdown', () => this._loop.quit());
}
async runAsync() {
this._service.register();
let flags = Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT;
if (programArgs.includes('--replace'))
flags |= Gio.BusNameOwnerFlags.REPLACE;
Gio.DBus.own_name(Gio.BusType.SESSION,
this._name,
flags,
() => this._service.export(),
null,
() => this._loop.quit());
await this._loop.runAsync();
}
}

View file

@ -0,0 +1,9 @@
.error-page preferencespage { margin: 30px; }
.expander { padding: 12px; }
.expander.expanded { border: 0 solid @borders; border-bottom-width: 1px; }
.expander-toolbar {
border: 0 solid @borders;
border-top-width: 1px;
padding: 3px;
}

View file

@ -0,0 +1,161 @@
import Adw from 'gi://Adw?version=1';
import Gdk from 'gi://Gdk?version=4.0';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk?version=4.0';
import {formatError} from './misc/errorUtils.js';
export const ExtensionPrefsDialog = GObject.registerClass({
GTypeName: 'ExtensionPrefsDialog',
Signals: {
'loaded': {},
},
}, class ExtensionPrefsDialog extends Adw.PreferencesWindow {
_init(extension) {
super._init({
title: extension.metadata.name,
search_enabled: false,
});
this._extension = extension;
this._loadPrefs().catch(e => {
this._showErrorPage(e);
logError(e, 'Failed to open preferences');
}).finally(() => this.emit('loaded'));
}
async _loadPrefs() {
const {dir, path, metadata} = this._extension;
const prefsJs = dir.get_child('prefs.js');
const prefsModule = await import(prefsJs.get_uri());
const prefsObj = new prefsModule.default({...metadata, dir, path});
this._extension.stateObj = prefsObj;
await prefsObj.fillPreferencesWindow(this);
if (!this.visible_page)
throw new Error('Extension did not provide any UI');
}
set titlebar(w) {
this.set_titlebar(w);
}
// eslint-disable-next-line camelcase
set_titlebar() {
// intercept fatal libadwaita error, show error page instead
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this._showErrorPage(
new Error('set_titlebar() is not supported for Adw.Window'));
return GLib.SOURCE_REMOVE;
});
}
destroy() {
this._showErrorPage(
new Error('destroy() breaks tracking open dialogs, use close() if you must'));
}
_showErrorPage(e) {
while (this.visible_page)
this.remove(this.visible_page);
this.add(new ExtensionPrefsErrorPage(this._extension, e));
}
});
const ExtensionPrefsErrorPage = GObject.registerClass({
GTypeName: 'ExtensionPrefsErrorPage',
Template: 'resource:///org/gnome/Shell/Extensions/ui/extension-error-page.ui',
InternalChildren: [
'expander',
'expanderArrow',
'revealer',
'errorView',
],
}, class ExtensionPrefsErrorPage extends Adw.PreferencesPage {
static _classInit(klass) {
super._classInit(klass);
klass.install_action('page.copy-error',
null,
self => {
const clipboard = self.get_display().get_clipboard();
clipboard.set(self._errorMarkdown);
});
klass.install_action('page.show-url',
null,
self => Gtk.show_uri(self.get_root(), self._url, Gdk.CURRENT_TIME));
return klass;
}
_init(extension, error) {
super._init();
this._addCustomStylesheet();
this._uuid = extension.uuid;
this._url = extension.metadata.url || '';
this.action_set_enabled('page.show-url', this._url !== '');
this._gesture = new Gtk.GestureClick({
button: 0,
exclusive: true,
});
this._expander.add_controller(this._gesture);
this._gesture.connect('released', (gesture, nPress) => {
if (nPress === 1)
this._revealer.reveal_child = !this._revealer.reveal_child;
});
this._revealer.connect('notify::reveal-child', () => {
this._expanderArrow.icon_name = this._revealer.reveal_child
? 'pan-down-symbolic'
: 'pan-end-symbolic';
this._syncExpandedStyle();
});
this._revealer.connect('notify::child-revealed',
() => this._syncExpandedStyle());
const formattedError = formatError(error);
this._errorView.buffer.text = formattedError;
// markdown for pasting in gitlab issues
let lines = [
`The settings of extension ${this._uuid} had an error:`,
'```',
formattedError.replace(/\n$/, ''), // remove trailing newline
'```',
'',
];
this._errorMarkdown = lines.join('\n');
}
_syncExpandedStyle() {
if (this._revealer.reveal_child)
this._expander.add_css_class('expanded');
else if (!this._revealer.child_revealed)
this._expander.remove_css_class('expanded');
}
_addCustomStylesheet() {
let provider = new Gtk.CssProvider();
let uri = 'resource:///org/gnome/Shell/Extensions/css/application.css';
try {
provider.load_from_file(Gio.File.new_for_uri(uri));
} catch (e) {
logError(e, 'Failed to add application style');
}
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
}
});

View file

@ -0,0 +1,175 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Shew from 'gi://Shew';
import {ExtensionPrefsDialog} from './extensionPrefsDialog.js';
import {ServiceImplementation} from './dbusService.js';
import {deserializeExtension} from './misc/extensionUtils.js';
import {loadInterfaceXML} from './misc/dbusUtils.js';
const ExtensionsIface = loadInterfaceXML('org.gnome.Shell.Extensions');
const ExtensionsProxy = Gio.DBusProxy.makeProxyWrapper(ExtensionsIface);
class ExtensionManager {
#extensions = new Map();
createExtensionObject(serialized) {
const extension = deserializeExtension(serialized);
this.#extensions.set(extension.uuid, extension);
return extension;
}
lookup(uuid) {
return this.#extensions.get(uuid);
}
}
export const extensionManager = new ExtensionManager();
export const ExtensionsService = class extends ServiceImplementation {
constructor() {
super(ExtensionsIface, '/org/gnome/Shell/Extensions');
this._proxy = new ExtensionsProxy(Gio.DBus.session,
'org.gnome.Shell', '/org/gnome/Shell');
this._proxy.connectSignal('ExtensionStateChanged',
(proxy, sender, params) => {
this._dbusImpl.emit_signal('ExtensionStateChanged',
new GLib.Variant('(sa{sv})', params));
});
this._proxy.connect('g-properties-changed', () => {
this._dbusImpl.emit_property_changed('UserExtensionsEnabled',
new GLib.Variant('b', this._proxy.UserExtensionsEnabled));
});
}
get ShellVersion() {
return this._proxy.ShellVersion;
}
get UserExtensionsEnabled() {
return this._proxy.UserExtensionsEnabled;
}
set UserExtensionsEnabled(enable) {
this._proxy.UserExtensionsEnabled = enable;
}
async ListExtensionsAsync(params, invocation) {
try {
const res = await this._proxy.ListExtensionsAsync(...params);
invocation.return_value(new GLib.Variant('(a{sa{sv}})', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async GetExtensionInfoAsync(params, invocation) {
try {
const res = await this._proxy.GetExtensionInfoAsync(...params);
invocation.return_value(new GLib.Variant('(a{sv})', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async GetExtensionErrorsAsync(params, invocation) {
try {
const res = await this._proxy.GetExtensionErrorsAsync(...params);
invocation.return_value(new GLib.Variant('(as)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async InstallRemoteExtensionAsync(params, invocation) {
try {
const res = await this._proxy.InstallRemoteExtensionAsync(...params);
invocation.return_value(new GLib.Variant('(s)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async UninstallExtensionAsync(params, invocation) {
try {
const res = await this._proxy.UninstallExtensionAsync(...params);
invocation.return_value(new GLib.Variant('(b)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async EnableExtensionAsync(params, invocation) {
try {
const res = await this._proxy.EnableExtensionAsync(...params);
invocation.return_value(new GLib.Variant('(b)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async DisableExtensionAsync(params, invocation) {
try {
const res = await this._proxy.DisableExtensionAsync(...params);
invocation.return_value(new GLib.Variant('(b)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
LaunchExtensionPrefsAsync([uuid], invocation) {
this.OpenExtensionPrefsAsync([uuid, '', {}], invocation);
}
async OpenExtensionPrefsAsync(params, invocation) {
const [uuid, parentWindow, options] = params;
try {
if (this._prefsDialog)
throw new Error('Already showing a prefs dialog');
const [serialized] = await this._proxy.GetExtensionInfoAsync(uuid);
const extension = extensionManager.createExtensionObject(serialized);
this._prefsDialog = new ExtensionPrefsDialog(extension);
this._prefsDialog.connect('loaded',
() => this._prefsDialog.show());
this._prefsDialog.connect('realize', () => {
let externalWindow = null;
if (parentWindow)
externalWindow = Shew.ExternalWindow.new_from_handle(parentWindow);
if (externalWindow)
externalWindow.set_parent_of(this._prefsDialog.get_surface());
});
if (options.modal)
this._prefsDialog.modal = options.modal.get_boolean();
this._prefsDialog.connect('close-request', () => {
delete this._prefsDialog;
this.release();
return false;
});
this.hold();
invocation.return_value(null);
} catch (error) {
this._handleError(invocation, error);
}
}
async CheckForUpdatesAsync(params, invocation) {
try {
await this._proxy.CheckForUpdatesAsync(...params);
invocation.return_value(null);
} catch (error) {
this._handleError(invocation, error);
}
}
};

View file

@ -0,0 +1,19 @@
import Adw from 'gi://Adw?version=1';
import GObject from 'gi://GObject';
const pkg = imports.package;
import {DBusService} from './dbusService.js';
import {ExtensionsService} from './extensionsService.js';
/** @returns {void} */
export async function main() {
Adw.init();
pkg.initFormat();
GObject.gtypeNameBasedOnJSPath = true;
const service = new DBusService(
'org.gnome.Shell.Extensions',
new ExtensionsService());
await service.runAsync();
}

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ExtensionPrefsErrorPage" parent="AdwPreferencesPage">
<style>
<class name="error-page"/>
</style>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Somethings gone wrong</property>
<style>
<class name="title-1"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Were very sorry, but theres been a problem: the settings for this extension cant be displayed. We recommend that you report the issue to the extension authors.</property>
<property name="justify">center</property>
<property name="wrap">True</property>
</object>
</child>
<child>
<object class="GtkFrame">
<property name="margin-top">12</property>
<child>
<object class="GtkBox">
<property name="hexpand">True</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="expander">
<property name="spacing">6</property>
<style>
<class name="expander"/>
</style>
<child>
<object class="GtkImage" id="expanderArrow">
<property name="icon-name">pan-end-symbolic</property>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Technical Details</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkRevealer" id="revealer">
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkTextView" id="errorView">
<property name="monospace">True</property>
<property name="editable">False</property>
<property name="wrap-mode">word</property>
<property name="left-margin">12</property>
<property name="right-margin">12</property>
<property name="top-margin">12</property>
<property name="bottom-margin">12</property>
</object>
</child>
<child>
<object class="GtkBox">
<style>
<class name="expander-toolbar"/>
</style>
<child>
<object class="GtkButton">
<property name="receives-default">True</property>
<property name="action-name">page.copy-error</property>
<property name="has-frame">False</property>
<property name="icon-name">edit-copy-symbolic</property>
</object>
</child>
<child>
<object class="GtkButton" id="homeButton">
<property name="visible"
bind-source="homeButton"
bind-property="sensitive"
bind-flags="sync-create"/>
<property name="hexpand">True</property>
<property name="halign">end</property>
<property name="label" translatable="yes">Homepage</property>
<property name="tooltip-text" translatable="yes">Visit extension homepage</property>
<property name="receives-default">True</property>
<property name="has-frame">False</property>
<property name="action-name">page.show-url</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>

View file

@ -0,0 +1,44 @@
launcherconf = configuration_data()
launcherconf.set('PACKAGE_NAME', meson.project_name())
launcherconf.set('prefix', prefix)
launcherconf.set('libdir', libdir)
dbus_services = {
'org.gnome.Shell.Extensions': 'extensions',
'org.gnome.Shell.Notifications': 'notifications',
'org.gnome.Shell.Screencast': 'screencast',
'org.gnome.ScreenSaver': 'screensaver',
}
config_dir = '@0@/..'.format(meson.current_build_dir())
foreach service, dir : dbus_services
configure_file(
input: 'dbus-service.in',
output: service,
configuration: launcherconf,
install_dir: pkgdatadir,
)
serviceconf = configuration_data()
serviceconf.set('service', service)
serviceconf.set('gjs', gjs.full_path())
serviceconf.set('pkgdatadir', pkgdatadir)
configure_file(
input: 'dbus-service.service.in',
output: service + '.service',
configuration: serviceconf,
install_dir: servicedir
)
gnome.compile_resources(
service + '.src',
service + '.src.gresource.xml',
dependencies: [config_js],
source_dir: ['.', '..', dir, config_dir],
gresource_bundle: true,
install: true,
install_dir: pkgdatadir
)
endforeach

View file

@ -0,0 +1,10 @@
import {DBusService} from './dbusService.js';
import {NotificationDaemon} from './notificationDaemon.js';
/** @returns {void} */
export async function main() {
const service = new DBusService(
'org.gnome.Shell.Notifications',
new NotificationDaemon());
await service.runAsync();
}

View file

@ -0,0 +1,168 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import {ServiceImplementation} from './dbusService.js';
import {loadInterfaceXML} from './misc/dbusUtils.js';
const NotificationsIface = loadInterfaceXML('org.freedesktop.Notifications');
const NotificationsProxy = Gio.DBusProxy.makeProxyWrapper(NotificationsIface);
Gio._promisify(Gio.DBusConnection.prototype, 'call');
export const NotificationDaemon = class extends ServiceImplementation {
constructor() {
super(NotificationsIface, '/org/freedesktop/Notifications');
this._autoShutdown = false;
this._activeNotifications = new Map();
this._proxy = new NotificationsProxy(Gio.DBus.session,
'org.gnome.Shell',
'/org/freedesktop/Notifications',
(proxy, error) => {
if (error)
log(error.message);
});
this._proxy.connectSignal('ActivationToken',
(proxy, sender, params) => {
const [id] = params;
this._emitSignal(
this._activeNotifications.get(id),
'ActivationToken',
new GLib.Variant('(us)', params));
});
this._proxy.connectSignal('ActionInvoked',
(proxy, sender, params) => {
const [id] = params;
this._emitSignal(
this._activeNotifications.get(id),
'ActionInvoked',
new GLib.Variant('(us)', params));
});
this._proxy.connectSignal('NotificationClosed',
(proxy, sender, params) => {
const [id] = params;
this._emitSignal(
this._activeNotifications.get(id),
'NotificationClosed',
new GLib.Variant('(uu)', params));
this._activeNotifications.delete(id);
});
}
_emitSignal(sender, signalName, params) {
if (!sender)
return;
this._dbusImpl.get_connection()?.emit_signal(
sender,
this._dbusImpl.get_object_path(),
'org.freedesktop.Notifications',
signalName,
params);
}
_untrackSender(sender) {
super._untrackSender(sender);
this._activeNotifications.forEach((value, key) => {
if (value === sender)
this._activeNotifications.delete(key);
});
}
_checkNotificationId(invocation, id) {
if (id === 0)
return true;
if (!this._activeNotifications.has(id))
return true;
if (this._activeNotifications.get(id) === invocation.get_sender())
return true;
const error = new GLib.Error(Gio.DBusError,
Gio.DBusError.INVALID_ARGS, 'Invalid notification ID');
this._handleError(invocation, error);
return false;
}
register() {
Gio.DBus.session.own_name(
'org.freedesktop.Notifications',
Gio.BusNameOwnerFlags.REPLACE,
null, null);
}
async NotifyAsync(params, invocation) {
const sender = invocation.get_sender();
const pid = await this._getSenderPid(sender);
const replaceId = params[1];
const hints = params[6];
if (!this._checkNotificationId(invocation, replaceId))
return;
params[6] = {
...hints,
'x-shell-sender-pid': new GLib.Variant('u', pid),
'x-shell-sender': new GLib.Variant('s', sender),
};
try {
const [id] = await this._proxy.NotifyAsync(...params);
this._activeNotifications.set(id, sender);
invocation.return_value(new GLib.Variant('(u)', [id]));
} catch (error) {
this._handleError(invocation, error);
}
}
async CloseNotificationAsync(params, invocation) {
const [id] = params;
if (!this._checkNotificationId(invocation, id))
return;
try {
await this._proxy.CloseNotificationAsync(...params);
invocation.return_value(null);
} catch (error) {
this._handleError(invocation, error);
}
}
async GetCapabilitiesAsync(params, invocation) {
try {
const res = await this._proxy.GetCapabilitiesAsync(...params);
invocation.return_value(new GLib.Variant('(as)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async GetServerInformationAsync(params, invocation) {
try {
const res = await this._proxy.GetServerInformationAsync(...params);
invocation.return_value(new GLib.Variant('(ssss)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async _getSenderPid(sender) {
const res = await Gio.DBus.session.call(
'org.freedesktop.DBus',
'/',
'org.freedesktop.DBus',
'GetConnectionUnixProcessID',
new GLib.Variant('(s)', [sender]),
new GLib.VariantType('(u)'),
Gio.DBusCallFlags.NONE,
-1,
null);
const [pid] = res.deepUnpack();
return pid;
}
};

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/ScreenSaver/js">
<file>main.js</file>
<file>screenSaverService.js</file>
<file>dbusService.js</file>
<file>misc/config.js</file>
<file>misc/dbusErrors.js</file>
<file>misc/dbusUtils.js</file>
</gresource>
</gresources>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/Shell/Extensions/js">
<file>main.js</file>
<file>extensionsService.js</file>
<file>extensionPrefsDialog.js</file>
<file>dbusService.js</file>
<file>extensions/sharedInternals.js</file>
<file>extensions/prefs.js</file>
<file>misc/config.js</file>
<file>misc/errorUtils.js</file>
<file>misc/extensionUtils.js</file>
<file>misc/dbusErrors.js</file>
<file>misc/dbusUtils.js</file>
<file>misc/params.js</file>
</gresource>
<gresource prefix="/org/gnome/Shell/Extensions">
<file>css/application.css</file>
<file>ui/extension-error-page.ui</file>
</gresource>
</gresources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/Shell/Notifications/js">
<file>main.js</file>
<file>notificationDaemon.js</file>
<file>dbusService.js</file>
<file>misc/config.js</file>
<file>misc/dbusErrors.js</file>
<file>misc/dbusUtils.js</file>
</gresource>
</gresources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/Shell/Screencast/js">
<file>main.js</file>
<file>screencastService.js</file>
<file>dbusService.js</file>
<file>misc/config.js</file>
<file>misc/dbusUtils.js</file>
<file>misc/dbusErrors.js</file>
<file>misc/signals.js</file>
<file>misc/signalTracker.js</file>
</gresource>
</gresources>

View file

@ -0,0 +1,13 @@
import {DBusService} from './dbusService.js';
import {ScreencastService} from './screencastService.js';
/** @returns {void} */
export async function main() {
if (!ScreencastService.canScreencast())
return;
const service = new DBusService(
'org.gnome.Shell.Screencast',
new ScreencastService());
await service.runAsync();
}

View file

@ -0,0 +1,808 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gst from 'gi://Gst?version=1.0';
import Gtk from 'gi://Gtk?version=4.0';
import {ServiceImplementation} from './dbusService.js';
import {ScreencastErrors, ScreencastError} from './misc/dbusErrors.js';
import {loadInterfaceXML, loadSubInterfaceXML} from './misc/dbusUtils.js';
import * as Signals from './misc/signals.js';
const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast');
const IntrospectIface = loadInterfaceXML('org.gnome.Shell.Introspect');
const IntrospectProxy = Gio.DBusProxy.makeProxyWrapper(IntrospectIface);
const ScreenCastIface = loadSubInterfaceXML(
'org.gnome.Mutter.ScreenCast', 'org.gnome.Mutter.ScreenCast');
const ScreenCastSessionIface = loadSubInterfaceXML(
'org.gnome.Mutter.ScreenCast.Session', 'org.gnome.Mutter.ScreenCast');
const ScreenCastStreamIface = loadSubInterfaceXML(
'org.gnome.Mutter.ScreenCast.Stream', 'org.gnome.Mutter.ScreenCast');
const ScreenCastProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastIface);
const ScreenCastSessionProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastSessionIface);
const ScreenCastStreamProxy = Gio.DBusProxy.makeProxyWrapper(ScreenCastStreamIface);
const DEFAULT_FRAMERATE = 30;
const DEFAULT_DRAW_CURSOR = true;
const PIPELINE_BLOCKLIST_FILENAME = 'gnome-shell-screencast-pipeline-blocklist';
const PIPELINES = [
{
id: 'hwenc-dmabuf-h264-vaapi-lp',
fileExtension: 'mp4',
pipelineString:
'capsfilter caps=video/x-raw(memory:DMABuf),format=DMA_DRM,max-framerate=%F/1 ! \
vapostproc ! \
vah264lpenc ! \
queue ! \
h264parse ! \
mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise',
},
{
id: 'hwenc-dmabuf-h264-vaapi',
fileExtension: 'mp4',
pipelineString:
'capsfilter caps=video/x-raw(memory:DMABuf),format=DMA_DRM,max-framerate=%F/1 ! \
vapostproc ! \
vah264enc ! \
queue ! \
h264parse ! \
mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise',
},
{
id: 'swenc-dmabuf-h264-openh264',
fileExtension: 'mp4',
pipelineString:
'capsfilter caps=video/x-raw(memory:DMABuf),max-framerate=%F/1 ! \
glupload ! glcolorconvert ! gldownload ! \
queue ! \
openh264enc deblocking=off background-detection=false complexity=low adaptive-quantization=false qp-max=26 qp-min=26 multi-thread=%T slice-mode=auto ! \
queue ! \
h264parse ! \
mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise',
},
{
id: 'swenc-memfd-h264-openh264',
fileExtension: 'mp4',
pipelineString:
'capsfilter caps=video/x-raw,max-framerate=%F/1 ! \
videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=%T ! \
queue ! \
openh264enc deblocking=off background-detection=false complexity=low adaptive-quantization=false qp-max=26 qp-min=26 multi-thread=%T slice-mode=auto ! \
queue ! \
h264parse ! \
mp4mux fragment-duration=500 fragment-mode=first-moov-then-finalise',
},
{
id: 'swenc-dmabuf-vp8-vp8enc',
fileExtension: 'webm',
pipelineString:
'capsfilter caps=video/x-raw(memory:DMABuf),max-framerate=%F/1 ! \
glupload ! glcolorconvert ! gldownload ! \
queue ! \
vp8enc cpu-used=16 max-quantizer=17 deadline=1 keyframe-mode=disabled threads=%T static-threshold=1000 buffer-size=20000 ! \
queue ! \
webmmux',
},
{
id: 'swenc-memfd-vp8-vp8enc',
fileExtension: 'webm',
pipelineString:
'capsfilter caps=video/x-raw,max-framerate=%F/1 ! \
videoconvert chroma-mode=none dither=none matrix-mode=output-only n-threads=%T ! \
queue ! \
vp8enc cpu-used=16 max-quantizer=17 deadline=1 keyframe-mode=disabled threads=%T static-threshold=1000 buffer-size=20000 ! \
queue ! \
webmmux',
},
];
const PipelineState = {
INIT: 'INIT',
STARTING: 'STARTING',
PLAYING: 'PLAYING',
FLUSHING: 'FLUSHING',
STOPPED: 'STOPPED',
ERROR: 'ERROR',
};
const SessionState = {
INIT: 'INIT',
ACTIVE: 'ACTIVE',
STOPPED: 'STOPPED',
};
class Recorder extends Signals.EventEmitter {
constructor(sessionPath, x, y, width, height, filePathStem, options,
invocation) {
super();
this._dbusConnection = invocation.get_connection();
this._x = x;
this._y = y;
this._width = width;
this._height = height;
this._filePathStem = filePathStem;
try {
const dir = Gio.File.new_for_path(filePathStem).get_parent();
dir.make_directory_with_parents(null);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
throw e;
}
this._pipelineString = null;
this._framerate = DEFAULT_FRAMERATE;
this._drawCursor = DEFAULT_DRAW_CURSOR;
this._blocklistFromPreviousCrashes = [];
const pipelineBlocklistPath = GLib.build_filenamev(
[GLib.get_user_runtime_dir(), PIPELINE_BLOCKLIST_FILENAME]);
this._pipelineBlocklistFile = Gio.File.new_for_path(pipelineBlocklistPath);
try {
const [success_, contents] = this._pipelineBlocklistFile.load_contents(null);
const decoder = new TextDecoder();
this._blocklistFromPreviousCrashes = JSON.parse(decoder.decode(contents));
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
throw e;
}
this._pipelineState = PipelineState.INIT;
this._pipeline = null;
this._applyOptions(options);
this._watchSender(invocation.get_sender());
this._sessionState = SessionState.INIT;
this._initSession(sessionPath);
}
_applyOptions(options) {
for (const option in options)
options[option] = options[option].deepUnpack();
if (options['pipeline'] !== undefined)
this._pipelineString = options['pipeline'];
if (options['framerate'] !== undefined)
this._framerate = options['framerate'];
if ('draw-cursor' in options)
this._drawCursor = options['draw-cursor'];
}
_addRecentItem() {
const file = Gio.File.new_for_path(this._filePath);
Gtk.RecentManager.get_default().add_item(file.get_uri());
}
_watchSender(sender) {
this._nameWatchId = this._dbusConnection.watch_name(
sender,
Gio.BusNameWatcherFlags.NONE,
null,
this._senderVanished.bind(this));
}
_unwatchSender() {
if (this._nameWatchId !== 0) {
this._dbusConnection.unwatch_name(this._nameWatchId);
this._nameWatchId = 0;
}
}
_teardownPipeline() {
if (!this._pipeline)
return;
if (this._pipeline.set_state(Gst.State.NULL) !== Gst.StateChangeReturn.SUCCESS)
log('Failed to set pipeline state to NULL');
this._pipelineState = PipelineState.STOPPED;
this._pipeline = null;
}
_stopSession() {
if (this._sessionState === SessionState.ACTIVE) {
this._sessionState = SessionState.STOPPED;
this._sessionProxy.StopSync();
}
}
_bailOutOnError(message, errorDomain = ScreencastErrors, errorCode = ScreencastError.RECORDER_ERROR) {
const error = new GLib.Error(errorDomain, errorCode, message);
// If it's a PIPELINE_ERROR, we want to leave the failing pipeline on the
// blocklist for the next time. Other errors are pipeline-independent, so
// reset the blocklist to allow the pipeline to be tried again next time.
if (!error.matches(ScreencastErrors, ScreencastError.PIPELINE_ERROR))
this._updateServiceCrashBlocklist([...this._blocklistFromPreviousCrashes]);
this._teardownPipeline();
this._unwatchSender();
this._stopSession();
if (this._startRequest) {
this._startRequest.reject(error);
delete this._startRequest;
}
if (this._stopRequest) {
this._stopRequest.reject(error);
delete this._stopRequest;
}
this.emit('error', error);
}
_handleFatalPipelineError(message, errorDomain, errorCode) {
this._pipelineState = PipelineState.ERROR;
this._bailOutOnError(message, errorDomain, errorCode);
}
_senderVanished() {
this._bailOutOnError('Sender has vanished');
}
_onSessionClosed() {
if (this._sessionState === SessionState.STOPPED)
return; // We closed the session ourselves
this._sessionState = SessionState.STOPPED;
this._bailOutOnError('Session closed unexpectedly');
}
_initSession(sessionPath) {
this._sessionProxy = new ScreenCastSessionProxy(Gio.DBus.session,
'org.gnome.Mutter.ScreenCast',
sessionPath);
this._sessionProxy.connectSignal('Closed', this._onSessionClosed.bind(this));
}
_updateServiceCrashBlocklist(blocklist) {
try {
if (blocklist.length === 0) {
this._pipelineBlocklistFile.delete(null);
} else {
this._pipelineBlocklistFile.replace_contents(
JSON.stringify(blocklist), null, false,
Gio.FileCreateFlags.NONE, null);
}
} catch (e) {
console.log(`Failed to update pipeline-blocklist file: ${e.message}`);
}
}
_tryNextPipeline() {
if (this._filePath) {
GLib.unlink(this._filePath);
delete this._filePath;
}
const {done, value: pipelineConfig} = this._pipelineConfigs.next();
if (done) {
this._handleFatalPipelineError('All pipelines failed to start',
ScreencastErrors, ScreencastError.ALL_PIPELINES_FAILED);
return;
}
if (this._blocklistFromPreviousCrashes.includes(pipelineConfig.id)) {
console.info(`Skipping pipeline '${pipelineConfig.id}' due to pipeline blocklist`);
this._tryNextPipeline();
return;
}
if (this._pipeline) {
if (this._pipeline.set_state(Gst.State.NULL) !== Gst.StateChangeReturn.SUCCESS)
log('Failed to set pipeline state to NULL');
this._pipeline = null;
}
try {
this._pipeline = this._createPipeline(this._nodeId, pipelineConfig,
this._framerate);
// Add the current pipeline to the blocklist, so it is skipped next
// time in case we crash; we'll remove it again on success or on
// non-pipeline-related failures.
this._updateServiceCrashBlocklist(
[...this._blocklistFromPreviousCrashes, pipelineConfig.id]);
} catch {
this._tryNextPipeline();
return;
}
const bus = this._pipeline.get_bus();
bus.add_watch(bus, this._onBusMessage.bind(this));
const retval = this._pipeline.set_state(Gst.State.PLAYING);
if (retval === Gst.StateChangeReturn.SUCCESS ||
retval === Gst.StateChangeReturn.ASYNC) {
// We'll wait for the state change message to PLAYING on the bus
} else {
this._tryNextPipeline();
}
}
*_getPipelineConfigs() {
if (this._pipelineString) {
yield {
pipelineString:
`capsfilter caps=video/x-raw,max-framerate=%F/1 ! ${this._pipelineString}`,
};
return;
}
const fallbackSupported =
Gst.Registry.get().check_feature_version('pipewiresrc', 0, 3, 67);
if (fallbackSupported)
yield* PIPELINES;
else
yield PIPELINES.at(-1);
}
startRecording() {
return new Promise((resolve, reject) => {
this._startRequest = {resolve, reject};
const [streamPath] = this._sessionProxy.RecordAreaSync(
this._x, this._y,
this._width, this._height,
{
'is-recording': GLib.Variant.new('b', true),
'cursor-mode': GLib.Variant.new('u', this._drawCursor ? 1 : 0),
});
this._streamProxy = new ScreenCastStreamProxy(Gio.DBus.session,
'org.gnome.Mutter.ScreenCast',
streamPath);
this._streamProxy.connectSignal('PipeWireStreamAdded',
(_proxy, _sender, params) => {
const [nodeId] = params;
this._nodeId = nodeId;
this._pipelineState = PipelineState.STARTING;
this._pipelineConfigs = this._getPipelineConfigs();
this._tryNextPipeline();
});
this._sessionProxy.StartSync();
this._sessionState = SessionState.ACTIVE;
});
}
stopRecording() {
if (this._startRequest)
return Promise.reject(new Error('Unable to stop recorder while still starting'));
return new Promise((resolve, reject) => {
this._stopRequest = {resolve, reject};
this._pipelineState = PipelineState.FLUSHING;
this._pipeline.send_event(Gst.Event.new_eos());
});
}
_onBusMessage(bus, message, _) {
switch (message.type) {
case Gst.MessageType.STATE_CHANGED: {
const [, newState] = message.parse_state_changed();
if (this._pipelineState === PipelineState.STARTING &&
message.src === this._pipeline &&
newState === Gst.State.PLAYING) {
this._pipelineState = PipelineState.PLAYING;
this._startRequest.resolve(this._filePath);
delete this._startRequest;
}
break;
}
case Gst.MessageType.EOS:
switch (this._pipelineState) {
case PipelineState.INIT:
case PipelineState.STOPPED:
case PipelineState.ERROR:
// In these cases there should be no pipeline, so should never happen
break;
case PipelineState.STARTING:
// This is something we can handle, try to switch to the next pipeline
this._tryNextPipeline();
break;
case PipelineState.PLAYING:
this._addRecentItem();
this._handleFatalPipelineError('Unexpected EOS message',
ScreencastErrors, ScreencastError.PIPELINE_ERROR);
break;
case PipelineState.FLUSHING:
// The pipeline ran successfully and we didn't crash; we can remove it
// from the blocklist again now.
this._updateServiceCrashBlocklist([...this._blocklistFromPreviousCrashes]);
this._addRecentItem();
this._teardownPipeline();
this._unwatchSender();
this._stopSession();
this._stopRequest.resolve();
delete this._stopRequest;
break;
default:
break;
}
break;
case Gst.MessageType.ERROR:
switch (this._pipelineState) {
case PipelineState.INIT:
case PipelineState.STOPPED:
case PipelineState.ERROR:
// In these cases there should be no pipeline, so should never happen
break;
case PipelineState.STARTING:
// This is something we can handle, try to switch to the next pipeline
this._tryNextPipeline();
break;
case PipelineState.PLAYING:
case PipelineState.FLUSHING: {
const [error] = message.parse_error();
if (error.matches(Gst.ResourceError, Gst.ResourceError.NO_SPACE_LEFT)) {
this._handleFatalPipelineError('Out of disk space',
ScreencastErrors, ScreencastError.OUT_OF_DISK_SPACE);
} else {
this._handleFatalPipelineError(
`GStreamer error while in state ${this._pipelineState}: ${error.message}`,
ScreencastErrors, ScreencastError.PIPELINE_ERROR);
}
break;
}
default:
break;
}
break;
default:
break;
}
return true;
}
_substituteVariables(pipelineDescr, framerate) {
const numProcessors = GLib.get_num_processors();
const numThreads = Math.min(Math.max(1, numProcessors), 64);
return pipelineDescr.replaceAll('%T', numThreads).replaceAll('%F', framerate);
}
_createPipeline(nodeId, pipelineConfig, framerate) {
const {fileExtension, pipelineString} = pipelineConfig;
const finalPipelineString = this._substituteVariables(pipelineString, framerate);
this._filePath = `${this._filePathStem}.${fileExtension}`;
const fullPipeline = `
pipewiresrc path=${nodeId}
do-timestamp=true
keepalive-time=1000
resend-last=true !
${finalPipelineString} !
filesink location="${this._filePath}"`;
return Gst.parse_launch_full(fullPipeline, null,
Gst.ParseFlags.FATAL_ERRORS);
}
}
export const ScreencastService = class extends ServiceImplementation {
static canScreencast() {
if (!Gst.init_check(null))
return false;
let elements = [
'pipewiresrc',
'filesink',
];
if (elements.some(e => Gst.ElementFactory.find(e) === null))
return false;
// The fallback pipeline must be available, the other ones are not
// guaranteed to work because they depend on hw encoders.
const fallbackPipeline = PIPELINES.at(-1);
elements = fallbackPipeline.pipelineString.split('!').map(
e => e.trim().split(' ').at(0));
if (elements.every(e => Gst.ElementFactory.find(e) !== null))
return true;
return false;
}
constructor() {
super(ScreencastIface, '/org/gnome/Shell/Screencast');
this.hold(); // gstreamer initializing can take a bit
this._canScreencast = ScreencastService.canScreencast();
Gst.init(null);
Gtk.init();
this.release();
this._recorders = new Map();
this._senders = new Map();
this._lockdownSettings = new Gio.Settings({
schema_id: 'org.gnome.desktop.lockdown',
});
this._proxy = new ScreenCastProxy(Gio.DBus.session,
'org.gnome.Mutter.ScreenCast',
'/org/gnome/Mutter/ScreenCast');
this._introspectProxy = new IntrospectProxy(Gio.DBus.session,
'org.gnome.Shell.Introspect',
'/org/gnome/Shell/Introspect');
}
get ScreencastSupported() {
return this._canScreencast;
}
_removeRecorder(sender) {
if (!this._recorders.delete(sender))
return;
if (this._recorders.size === 0)
this.release();
}
_addRecorder(sender, recorder) {
this._recorders.set(sender, recorder);
if (this._recorders.size === 1)
this.hold();
}
_getAbsolutePath(filename) {
if (GLib.path_is_absolute(filename))
return filename;
const videoDir =
GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS) ||
GLib.get_home_dir();
return GLib.build_filenamev([videoDir, filename]);
}
_generateFilePath(template) {
let filename = '';
let escape = false;
// FIXME: temporarily detect and strip .webm prefix to avoid breaking
// external consumers of our API, remove this again
if (template.endsWith('.webm')) {
console.log("'file_template' for screencast includes '.webm' file-extension. Passing the file-extension as part of the filename has been deprecated, pass the 'file_template' without a file-extension instead.");
template = template.substring(0, template.length - '.webm'.length);
}
[...template].forEach(c => {
if (escape) {
switch (c) {
case '%':
filename += '%';
break;
case 'd': {
const datetime = GLib.DateTime.new_now_local();
const datestr = datetime.format('%Y-%m-%d');
filename += datestr;
break;
}
case 't': {
const datetime = GLib.DateTime.new_now_local();
const datestr = datetime.format('%H-%M-%S');
filename += datestr;
break;
}
default:
log(`Warning: Unknown escape ${c}`);
}
escape = false;
} else if (c === '%') {
escape = true;
} else {
filename += c;
}
});
if (escape)
filename += '%';
return this._getAbsolutePath(filename);
}
async ScreencastAsync(params, invocation) {
if (this._lockdownSettings.get_boolean('disable-save-to-disk')) {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.SAVE_TO_DISK_DISABLED,
'Saving to disk is disabled');
return;
}
const sender = invocation.get_sender();
if (this._recorders.get(sender)) {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.ALREADY_RECORDING,
'Service is already recording');
return;
}
const [sessionPath] = this._proxy.CreateSessionSync({});
const [fileTemplate, options] = params;
const [screenWidth, screenHeight] = this._introspectProxy.ScreenSize;
const filePathStem = this._generateFilePath(fileTemplate);
let recorder;
try {
recorder = new Recorder(
sessionPath,
0, 0,
screenWidth, screenHeight,
filePathStem,
options,
invocation);
} catch (error) {
log(`Failed to create recorder: ${error.message}`);
invocation.return_error_literal(ScreencastErrors,
ScreencastError.RECORDER_ERROR,
error.message);
return;
}
this._addRecorder(sender, recorder);
try {
const pathWithExtension = await recorder.startRecording();
invocation.return_value(GLib.Variant.new('(bs)', [true, pathWithExtension]));
} catch (error) {
log(`Failed to start recorder: ${error.message}`);
this._removeRecorder(sender);
if (error instanceof GLib.Error) {
invocation.return_gerror(error);
} else {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.RECORDER_ERROR,
error.message);
}
return;
}
recorder.connect('error', (r, error) => {
log(`Fatal error while recording: ${error.message}`);
this._removeRecorder(sender);
this._dbusImpl.emit_signal('Error',
new GLib.Variant('(ss)', [
Gio.DBusError.encode_gerror(error),
error.message,
]));
});
}
async ScreencastAreaAsync(params, invocation) {
if (this._lockdownSettings.get_boolean('disable-save-to-disk')) {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.SAVE_TO_DISK_DISABLED,
'Saving to disk is disabled');
return;
}
const sender = invocation.get_sender();
if (this._recorders.get(sender)) {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.ALREADY_RECORDING,
'Service is already recording');
return;
}
const [sessionPath] = this._proxy.CreateSessionSync({});
const [x, y, width, height, fileTemplate, options] = params;
const filePathStem = this._generateFilePath(fileTemplate);
let recorder;
try {
recorder = new Recorder(
sessionPath,
x, y,
width, height,
filePathStem,
options,
invocation);
} catch (error) {
log(`Failed to create recorder: ${error.message}`);
invocation.return_error_literal(ScreencastErrors,
ScreencastError.RECORDER_ERROR,
error.message);
return;
}
this._addRecorder(sender, recorder);
try {
const pathWithExtension = await recorder.startRecording();
invocation.return_value(GLib.Variant.new('(bs)', [true, pathWithExtension]));
} catch (error) {
log(`Failed to start recorder: ${error.message}`);
this._removeRecorder(sender);
if (error instanceof GLib.Error) {
invocation.return_gerror(error);
} else {
invocation.return_error_literal(ScreencastErrors,
ScreencastError.RECORDER_ERROR,
error.message);
}
return;
}
recorder.connect('error', (r, error) => {
log(`Fatal error while recording: ${error.message}`);
this._removeRecorder(sender);
this._dbusImpl.emit_signal('Error',
new GLib.Variant('(ss)', [
Gio.DBusError.encode_gerror(error),
error.message,
]));
});
}
async StopScreencastAsync(params, invocation) {
const sender = invocation.get_sender();
const recorder = this._recorders.get(sender);
if (!recorder) {
invocation.return_value(GLib.Variant.new('(b)', [false]));
return;
}
try {
await recorder.stopRecording();
} catch (error) {
log(`${sender}: Error while stopping recorder: ${error.message}`);
} finally {
this._removeRecorder(sender);
invocation.return_value(GLib.Variant.new('(b)', [true]));
}
}
};

View file

@ -0,0 +1,10 @@
import {DBusService} from './dbusService.js';
import {ScreenSaverService} from './screenSaverService.js';
/** @returns {void} */
export async function main() {
const service = new DBusService(
'org.gnome.ScreenSaver',
new ScreenSaverService());
await service.runAsync();
}

View file

@ -0,0 +1,69 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import {ServiceImplementation} from './dbusService.js';
import {loadInterfaceXML} from './misc/dbusUtils.js';
const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
const ScreenSaverProxy = Gio.DBusProxy.makeProxyWrapper(ScreenSaverIface);
export const ScreenSaverService = class extends ServiceImplementation {
constructor() {
super(ScreenSaverIface, '/org/gnome/ScreenSaver');
this._autoShutdown = false;
this._proxy = new ScreenSaverProxy(Gio.DBus.session,
'org.gnome.Shell.ScreenShield',
'/org/gnome/ScreenSaver',
(proxy, error) => {
if (error)
log(error.message);
});
this._proxy.connectSignal('ActiveChanged',
(proxy, sender, params) => {
this._dbusImpl.emit_signal('ActiveChanged',
new GLib.Variant('(b)', params));
});
this._proxy.connectSignal('WakeUpScreen',
() => this._dbusImpl.emit_signal('WakeUpScreen', null));
}
async LockAsync(params, invocation) {
try {
await this._proxy.LockAsync(...params);
invocation.return_value(null);
} catch (error) {
this._handleError(invocation, error);
}
}
async GetActiveAsync(params, invocation) {
try {
const res = await this._proxy.GetActiveAsync(...params);
invocation.return_value(new GLib.Variant('(b)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
async SetActiveAsync(params, invocation) {
try {
await this._proxy.SetActiveAsync(...params);
invocation.return_value(null);
} catch (error) {
this._handleError(invocation, error);
}
}
async GetActiveTimeAsync(params, invocation) {
try {
const res = await this._proxy.GetActiveTimeAsync(...params);
invocation.return_value(new GLib.Variant('(u)', res));
} catch (error) {
this._handleError(invocation, error);
}
}
};

110
js/extensions/extension.js Normal file
View file

@ -0,0 +1,110 @@
const Gi = imports._gi;
import {ExtensionBase, GettextWrapper} from './sharedInternals.js';
import {extensionManager} from '../ui/main.js';
export class Extension extends ExtensionBase {
static lookupByUUID(uuid) {
return extensionManager.lookup(uuid)?.stateObj ?? null;
}
static defineTranslationFunctions(url) {
const wrapper = new GettextWrapper(this, url);
return wrapper.defineTranslationFunctions();
}
enable() {
}
disable() {
}
/**
* Open the extension's preferences window
*/
openPreferences() {
extensionManager.openExtensionPrefs(this.uuid, '', {});
}
}
export const {
gettext, ngettext, pgettext,
} = Extension.defineTranslationFunctions();
export class InjectionManager {
#savedMethods = new Map();
/**
* @callback CreateOverrideFunc
* @param {Function?} originalMethod - the original method if it exists
* @returns {Function} - a function to be used as override
*/
/**
* Modify, replace or inject a method
*
* @param {object} prototype - the object (or prototype) that is modified
* @param {string} methodName - the name of the overwritten method
* @param {CreateOverrideFunc} createOverrideFunc - function to call to create the override
*/
overrideMethod(prototype, methodName, createOverrideFunc) {
const originalMethod = this._saveMethod(prototype, methodName);
this._installMethod(prototype, methodName, createOverrideFunc(originalMethod));
}
/**
* Restore the original method
*
* @param {object} prototype - the object (or prototype) that is modified
* @param {string} methodName - the name of the method to restore
*/
restoreMethod(prototype, methodName) {
const savedProtoMethods = this.#savedMethods.get(prototype);
if (!savedProtoMethods)
return;
const originalMethod = savedProtoMethods.get(methodName);
if (originalMethod === undefined)
delete prototype[methodName];
else
this._installMethod(prototype, methodName, originalMethod);
savedProtoMethods.delete(methodName);
if (savedProtoMethods.size === 0)
this.#savedMethods.delete(prototype);
}
/**
* Restore all original methods and clear overrides
*/
clear() {
for (const [proto, map] of this.#savedMethods) {
map.forEach(
(_, methodName) => this.restoreMethod(proto, methodName));
}
console.assert(this.#savedMethods.size === 0,
`${this.#savedMethods.size} overrides left after clear()`);
}
_saveMethod(prototype, methodName) {
let savedProtoMethods = this.#savedMethods.get(prototype);
if (!savedProtoMethods) {
savedProtoMethods = new Map();
this.#savedMethods.set(prototype, savedProtoMethods);
}
const originalMethod = prototype[methodName];
savedProtoMethods.set(methodName, originalMethod);
return originalMethod;
}
_installMethod(prototype, methodName, method) {
if (methodName.startsWith('vfunc_')) {
const giPrototype = prototype[Gi.gobject_prototype_symbol];
giPrototype[Gi.hook_up_vfunc_symbol](methodName.slice(6), method);
} else {
prototype[methodName] = method;
}
}
}

62
js/extensions/prefs.js Normal file
View file

@ -0,0 +1,62 @@
import Adw from 'gi://Adw';
import GObject from 'gi://GObject';
import {ExtensionBase, GettextWrapper} from './sharedInternals.js';
import {extensionManager} from '../extensionsService.js';
export class ExtensionPreferences extends ExtensionBase {
static lookupByUUID(uuid) {
return extensionManager.lookup(uuid)?.stateObj ?? null;
}
static defineTranslationFunctions(url) {
const wrapper = new GettextWrapper(this, url);
return wrapper.defineTranslationFunctions();
}
/**
* Get the single widget that implements
* the extension's preferences.
*
* @returns {Gtk.Widget|Promise<Gtk.Widget>}
*/
getPreferencesWidget() {
throw new GObject.NotImplementedError();
}
/**
* Fill the preferences window with preferences.
*
* The default implementation adds the widget
* returned by getPreferencesWidget().
*
* @param {Adw.PreferencesWindow} window - the preferences window
* @returns {Promise<void>}
*/
async fillPreferencesWindow(window) {
const widget = await this.getPreferencesWidget();
const page = this._wrapWidget(widget);
window.add(page);
}
_wrapWidget(widget) {
if (widget instanceof Adw.PreferencesPage)
return widget;
const page = new Adw.PreferencesPage();
if (widget instanceof Adw.PreferencesGroup) {
page.add(widget);
return page;
}
const group = new Adw.PreferencesGroup();
group.add(widget);
page.add(group);
return page;
}
}
export const {
gettext, ngettext, pgettext,
} = ExtensionPreferences.defineTranslationFunctions();

View file

@ -0,0 +1,348 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import {bindtextdomain} from 'gettext';
import * as Config from '../misc/config.js';
export class ExtensionBase {
#gettextDomain;
#console;
/**
* Look up an extension by URL (usually 'import.meta.url')
*
* @param {string} url - a file:// URL
*/
static lookupByURL(url) {
if (!url.startsWith('file://'))
return null;
// Keep the last '/' from 'file://' to force an absolute path
let path = url.slice(6);
// Walk up the directory tree, looking for an extension with
// the same UUID as a directory name.
do {
path = GLib.path_get_dirname(path);
const dirName = GLib.path_get_basename(path);
const extension = this.lookupByUUID(dirName);
if (extension !== null)
return extension;
} while (path !== '/');
return null;
}
/**
* Look up an extension by UUID
*
* @param {string} _uuid
*/
static lookupByUUID(_uuid) {
throw new GObject.NotImplementedError();
}
/**
* @param {object} metadata - metadata passed in when loading the extension
*/
constructor(metadata) {
if (this.constructor === ExtensionBase)
throw new Error('ExtensionBase cannot be used directly.');
if (!metadata)
throw new Error(`${this.constructor.name} did not pass metadata to parent`);
this.metadata = metadata;
this.initTranslations();
}
/**
* @type {string}
*/
get uuid() {
return this.metadata['uuid'];
}
/**
* @type {Gio.File}
*/
get dir() {
return this.metadata['dir'];
}
/**
* @type {string}
*/
get path() {
return this.metadata['path'];
}
/**
* Get a GSettings object for schema, using schema files in
* extensionsdir/schemas. If schema is omitted, it is taken
* from metadata['settings-schema'].
*
* @param {string=} schema - the GSettings schema id
*
* @returns {Gio.Settings}
*/
getSettings(schema) {
schema ||= this.metadata['settings-schema'];
// Expect USER extensions to have a schemas/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const schemaDir = this.dir.get_child('schemas');
const defaultSource = Gio.SettingsSchemaSource.get_default();
let schemaSource;
if (schemaDir.query_exists(null)) {
schemaSource = Gio.SettingsSchemaSource.new_from_directory(
schemaDir.get_path(), defaultSource, false);
} else {
schemaSource = defaultSource;
}
const schemaObj = schemaSource.lookup(schema, true);
if (!schemaObj)
throw new Error(`Schema ${schema} could not be found for extension ${this.uuid}. Please check your installation`);
return new Gio.Settings({settings_schema: schemaObj});
}
/**
* @returns {Console}
*/
getLogger() {
if (!this.#console)
this.#console = new Console(this);
return this.#console;
}
/**
* Initialize Gettext to load translations from extensionsdir/locale. If
* domain is not provided, it will be taken from metadata['gettext-domain']
* if provided, or use the UUID
*
* @param {string=} domain - the gettext domain to use
*/
initTranslations(domain) {
domain ||= this.metadata['gettext-domain'] ?? this.uuid;
// Expect USER extensions to have a locale/ subfolder, otherwise assume a
// SYSTEM extension that has been installed in the same prefix as the shell
const localeDir = this.dir.get_child('locale');
if (localeDir.query_exists(null))
bindtextdomain(domain, localeDir.get_path());
else
bindtextdomain(domain, Config.LOCALEDIR);
this.#gettextDomain = domain;
}
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext(str) {
this.#checkGettextDomain('gettext');
return GLib.dgettext(this.#gettextDomain, str);
}
/**
* Translate `str` and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} the translated string
*/
ngettext(str, strPlural, n) {
this.#checkGettextDomain('ngettext');
return GLib.dngettext(this.#gettextDomain, str, strPlural, n);
}
/**
* Translate `str` in the context of `context` using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate `str`
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
pgettext(context, str) {
this.#checkGettextDomain('pgettext');
return GLib.dpgettext2(this.#gettextDomain, context, str);
}
/**
* @param {string} func
*/
#checkGettextDomain(func) {
if (!this.#gettextDomain)
throw new Error(`${func}() is used without calling initTranslations() first`);
}
}
export class GettextWrapper {
#url;
#extensionClass;
constructor(extensionClass, url) {
this.#url = url;
this.#extensionClass = extensionClass;
}
#detectUrl() {
const basePath = '/gnome-shell/extensions/';
// Search for an occurrence of an extension stack frame
// Start at 1 because 0 is the stack frame of this function
const [, ...stack] = new Error().stack.split('\n');
const extensionLine = stack.find(
line => line.includes(basePath));
if (!extensionLine)
return null;
// The exact stack line differs depending on where the function
// was called (function or module scope), and whether it's called
// from a module or legacy import (file:// URI vs. plain path).
//
// We don't have to care about the exact composition, all we need
// is a string that can be traversed as path and contains the UUID
const path = extensionLine.slice(extensionLine.indexOf(basePath));
return `file://${path}`;
}
#lookupExtension(funcName) {
const url = this.#url ?? this.#detectUrl();
const extension = this.#extensionClass.lookupByURL(url);
if (!extension)
throw new Error(`${funcName} can only be called from extensions`);
return extension;
}
#gettext(str) {
const extension = this.#lookupExtension('gettext');
return extension.gettext(str);
}
#ngettext(str, strPlural, n) {
const extension = this.#lookupExtension('ngettext');
return extension.ngettext(str, strPlural, n);
}
#pgettext(context, str) {
const extension = this.#lookupExtension('pgettext');
return extension.pgettext(context, str);
}
defineTranslationFunctions() {
return {
/**
* Translate `str` using the extension's gettext domain
*
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
gettext: this.#gettext.bind(this),
/**
* Translate `str` and choose plural form using the extension's
* gettext domain
*
* @param {string} str - the string to translate
* @param {string} strPlural - the plural form of the string
* @param {number} n - the quantity for which translation is needed
*
* @returns {string} the translated string
*/
ngettext: this.#ngettext.bind(this),
/**
* Translate `str` in the context of `context` using the extension's
* gettext domain
*
* @param {string} context - context to disambiguate `str`
* @param {string} str - the string to translate
*
* @returns {string} the translated string
*/
pgettext: this.#pgettext.bind(this),
};
}
}
class Console {
#extension;
constructor(ext) {
this.#extension = ext;
}
#prefixArgs(first, ...args) {
return [`[${this.#extension.metadata.name}] ${first}`, ...args];
}
log(...args) {
globalThis.console.log(...this.#prefixArgs(...args));
}
warn(...args) {
globalThis.console.warn(...this.#prefixArgs(...args));
}
error(...args) {
globalThis.console.error(...this.#prefixArgs(...args));
}
info(...args) {
globalThis.console.info(...this.#prefixArgs(...args));
}
debug(...args) {
globalThis.console.debug(...this.#prefixArgs(...args));
}
assert(condition, ...args) {
if (condition)
return;
const message = 'Assertion failed';
if (args.length === 0)
args.push(message);
if (typeof args[0] !== 'string') {
args.unshift(message);
} else {
const first = args.shift();
args.unshift(`${message}: ${first}`);
}
globalThis.console.error(...this.#prefixArgs(...args));
}
trace(...args) {
if (args.length === 0)
args = ['Trace'];
globalThis.console.trace(...this.#prefixArgs(...args));
}
group(...args) {
globalThis.console.group(...this.#prefixArgs(...args));
}
groupEnd() {
globalThis.console.groupEnd();
}
}

176
js/gdm/authList.js Normal file
View file

@ -0,0 +1,176 @@
/*
* Copyright 2017 Red Hat, Inc
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, see <http://www.gnu.org/licenses/>.
*/
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import St from 'gi://St';
const SCROLL_ANIMATION_TIME = 500;
const AuthListItem = GObject.registerClass({
Signals: {'activate': {}},
}, class AuthListItem extends St.Button {
_init(key, text) {
this.key = key;
const label = new St.Label({
text,
style_class: 'login-dialog-auth-list-label',
y_align: Clutter.ActorAlign.CENTER,
x_expand: true,
});
super._init({
style_class: 'login-dialog-auth-list-item',
button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
can_focus: true,
child: label,
reactive: true,
});
this.connect('key-focus-in',
() => this._setSelected(true));
this.connect('key-focus-out',
() => this._setSelected(false));
this.connect('notify::hover',
() => this._setSelected(this.hover));
this.connect('clicked', this._onClicked.bind(this));
}
_onClicked() {
this.emit('activate');
}
_setSelected(selected) {
if (selected) {
this.add_style_pseudo_class('selected');
this.grab_key_focus();
} else {
this.remove_style_pseudo_class('selected');
}
}
});
export const AuthList = GObject.registerClass({
Signals: {
'activate': {param_types: [GObject.TYPE_STRING]},
'item-added': {param_types: [AuthListItem.$gtype]},
},
}, class AuthList extends St.BoxLayout {
_init() {
super._init({
orientation: Clutter.Orientation.VERTICAL,
style_class: 'login-dialog-auth-list-layout',
x_align: Clutter.ActorAlign.START,
y_align: Clutter.ActorAlign.CENTER,
});
this.label = new St.Label({style_class: 'login-dialog-auth-list-title'});
this.add_child(this.label);
this._box = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
style_class: 'login-dialog-auth-list',
pseudo_class: 'expanded',
});
this._scrollView = new St.ScrollView({
style_class: 'login-dialog-auth-list-view',
child: this._box,
});
this.add_child(this._scrollView);
this._items = new Map();
this.connect('key-focus-in', this._moveFocusToItems.bind(this));
}
_moveFocusToItems() {
let hasItems = this.numItems > 0;
if (!hasItems)
return;
if (global.stage.get_key_focus() !== this)
return;
let focusSet = this.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
if (!focusSet) {
const laters = global.compositor.get_laters();
laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
this._moveFocusToItems();
return false;
});
}
}
_onItemActivated(activatedItem) {
this.emit('activate', activatedItem.key);
}
scrollToItem(item) {
let box = item.get_allocation_box();
const adjustment = this._scrollView.vadjustment;
let value = (box.y1 + adjustment.step_increment / 2.0) - (adjustment.page_size / 2.0);
adjustment.ease(value, {
duration: SCROLL_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
addItem(key, text) {
this.removeItem(key);
let item = new AuthListItem(key, text);
this._box.add_child(item);
this._items.set(key, item);
item.connect('activate', this._onItemActivated.bind(this));
// Try to keep the focused item front-and-center
item.connect('key-focus-in', () => this.scrollToItem(item));
this._moveFocusToItems();
this.emit('item-added', item);
}
removeItem(key) {
if (!this._items.has(key))
return;
let item = this._items.get(key);
item.destroy();
this._items.delete(key);
}
get numItems() {
return this._items.size;
}
clear() {
this.label.text = '';
this._box.destroy_all_children();
this._items.clear();
}
});

717
js/gdm/authPrompt.js Normal file
View file

@ -0,0 +1,717 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Animation from '../ui/animation.js';
import * as AuthList from './authList.js';
import * as Batch from './batch.js';
import * as GdmUtil from './util.js';
import * as Params from '../misc/params.js';
import * as ShellEntry from '../ui/shellEntry.js';
import * as UserWidget from '../ui/userWidget.js';
import {wiggle} from '../misc/animationUtils.js';
const DEFAULT_BUTTON_WELL_ICON_SIZE = 16;
const DEFAULT_BUTTON_WELL_ANIMATION_DELAY = 1000;
const DEFAULT_BUTTON_WELL_ANIMATION_TIME = 300;
const MESSAGE_FADE_OUT_ANIMATION_TIME = 500;
/** @enum {number} */
export const AuthPromptMode = {
UNLOCK_ONLY: 0,
UNLOCK_OR_LOG_IN: 1,
};
/** @enum {number} */
export const AuthPromptStatus = {
NOT_VERIFYING: 0,
VERIFYING: 1,
VERIFICATION_FAILED: 2,
VERIFICATION_SUCCEEDED: 3,
VERIFICATION_CANCELLED: 4,
VERIFICATION_IN_PROGRESS: 5,
};
/** @enum {number} */
export const BeginRequestType = {
PROVIDE_USERNAME: 0,
DONT_PROVIDE_USERNAME: 1,
REUSE_USERNAME: 2,
};
export const AuthPrompt = GObject.registerClass({
Signals: {
'cancelled': {},
'failed': {},
'next': {},
'prompted': {},
'reset': {param_types: [GObject.TYPE_UINT]},
},
}, class AuthPrompt extends St.BoxLayout {
_init(gdmClient, mode) {
super._init({
style_class: 'login-dialog-prompt-layout',
orientation: Clutter.Orientation.VERTICAL,
x_expand: true,
x_align: Clutter.ActorAlign.CENTER,
reactive: true,
});
this.verificationStatus = AuthPromptStatus.NOT_VERIFYING;
this._gdmClient = gdmClient;
this._mode = mode;
this._defaultButtonWellActor = null;
this._cancelledRetries = 0;
let reauthenticationOnly;
if (this._mode === AuthPromptMode.UNLOCK_ONLY)
reauthenticationOnly = true;
else if (this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN)
reauthenticationOnly = false;
this._userVerifier = this._createUserVerifier(this._gdmClient, {reauthenticationOnly});
this._userVerifier.connect('ask-question', this._onAskQuestion.bind(this));
this._userVerifier.connect('show-message', this._onShowMessage.bind(this));
this._userVerifier.connect('show-choice-list', this._onShowChoiceList.bind(this));
this._userVerifier.connect('verification-failed', this._onVerificationFailed.bind(this));
this._userVerifier.connect('verification-complete', this._onVerificationComplete.bind(this));
this._userVerifier.connect('reset', this._onReset.bind(this));
this._userVerifier.connect('smartcard-status-changed', this._onSmartcardStatusChanged.bind(this));
this._userVerifier.connect('credential-manager-authenticated', this._onCredentialManagerAuthenticated.bind(this));
this.smartcardDetected = this._userVerifier.smartcardDetected;
this.connect('destroy', this._onDestroy.bind(this));
this._userWell = new St.Bin({
x_expand: true,
y_expand: true,
});
this.add_child(this._userWell);
this._hasCancelButton = this._mode === AuthPromptMode.UNLOCK_OR_LOG_IN;
this._initInputRow();
let capsLockPlaceholder = new St.Label();
this.add_child(capsLockPlaceholder);
this._capsLockWarningLabel = new ShellEntry.CapsLockWarning({
x_expand: true,
x_align: Clutter.ActorAlign.CENTER,
});
this.add_child(this._capsLockWarningLabel);
this._capsLockWarningLabel.bind_property('visible',
capsLockPlaceholder, 'visible',
GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.INVERT_BOOLEAN);
this._message = new St.Label({
opacity: 0,
styleClass: 'login-dialog-message',
y_expand: true,
x_expand: true,
y_align: Clutter.ActorAlign.START,
x_align: Clutter.ActorAlign.CENTER,
});
this._message.clutter_text.line_wrap = true;
this._message.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this.add_child(this._message);
}
_createUserVerifier(gdmClient, params) {
return new GdmUtil.ShellUserVerifier(gdmClient, params);
}
_onDestroy() {
this._inactiveEntry.destroy();
this._inactiveEntry = null;
this._userVerifier.destroy();
this._userVerifier = null;
}
on_key_press_event(event) {
if (event.get_key_symbol() === Clutter.KEY_Escape) {
this.cancel();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
}
_initInputRow() {
this._mainBox = new St.BoxLayout({
style_class: 'login-dialog-button-box',
orientation: Clutter.Orientation.HORIZONTAL,
});
this.add_child(this._mainBox);
this.cancelButton = new St.Button({
style_class: 'login-dialog-button cancel-button',
accessible_name: _('Cancel'),
button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
reactive: this._hasCancelButton,
can_focus: this._hasCancelButton,
x_align: Clutter.ActorAlign.START,
y_align: Clutter.ActorAlign.CENTER,
icon_name: 'go-previous-symbolic',
});
if (this._hasCancelButton)
this.cancelButton.connect('clicked', () => this.cancel());
else
this.cancelButton.opacity = 0;
this._mainBox.add_child(this.cancelButton);
this._authList = new AuthList.AuthList();
this._authList.set({
visible: false,
x_align: Clutter.ActorAlign.FILL,
x_expand: true,
});
this._authList.connect('activate', (list, key) => {
this._authList.reactive = false;
this._authList.ease({
opacity: 0,
duration: MESSAGE_FADE_OUT_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
this._authList.clear();
this._authList.hide();
this._userVerifier.selectChoice(this._queryingService, key);
},
});
});
this._mainBox.add_child(this._authList);
let entryParams = {
style_class: 'login-dialog-prompt-entry',
can_focus: true,
x_expand: true,
};
this._entry = null;
this._textEntry = new St.Entry(entryParams);
ShellEntry.addContextMenu(this._textEntry, {actionMode: Shell.ActionMode.NONE});
this._passwordEntry = new St.PasswordEntry(entryParams);
ShellEntry.addContextMenu(this._passwordEntry, {actionMode: Shell.ActionMode.NONE});
this._entry = this._passwordEntry;
this._mainBox.add_child(this._entry);
this._entry.grab_key_focus();
this._inactiveEntry = this._textEntry;
this._timedLoginIndicator = new St.Bin({
style_class: 'login-dialog-timed-login-indicator',
scale_x: 0,
});
this.add_child(this._timedLoginIndicator);
[this._textEntry, this._passwordEntry].forEach(entry => {
entry.clutter_text.connect('text-changed', () => {
if (!this._userVerifier.hasPendingMessages)
this._fadeOutMessage();
});
entry.clutter_text.connect('activate', () => {
let shouldSpin = entry === this._passwordEntry;
if (entry.reactive)
this._activateNext(shouldSpin);
});
});
this._defaultButtonWell = new St.Widget({
layout_manager: new Clutter.BinLayout(),
x_align: Clutter.ActorAlign.END,
y_align: Clutter.ActorAlign.CENTER,
});
this._defaultButtonWell.add_constraint(new Clutter.BindConstraint({
source: this.cancelButton,
coordinate: Clutter.BindCoordinate.WIDTH,
}));
this._mainBox.add_child(this._defaultButtonWell);
this._spinner = new Animation.Spinner(DEFAULT_BUTTON_WELL_ICON_SIZE);
this._defaultButtonWell.add_child(this._spinner);
}
showTimedLoginIndicator(time) {
let hold = new Batch.Hold();
this.hideTimedLoginIndicator();
const startTime = GLib.get_monotonic_time();
this._timedLoginTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33,
() => {
const currentTime = GLib.get_monotonic_time();
const elapsedTime = (currentTime - startTime) / GLib.USEC_PER_SEC;
this._timedLoginIndicator.scale_x = elapsedTime / time;
if (elapsedTime >= time) {
this._timedLoginTimeoutId = 0;
hold.release();
return GLib.SOURCE_REMOVE;
}
return GLib.SOURCE_CONTINUE;
});
GLib.Source.set_name_by_id(this._timedLoginTimeoutId, '[gnome-shell] this._timedLoginTimeoutId');
return hold;
}
hideTimedLoginIndicator() {
if (this._timedLoginTimeoutId) {
GLib.source_remove(this._timedLoginTimeoutId);
this._timedLoginTimeoutId = 0;
}
this._timedLoginIndicator.scale_x = 0.;
}
_activateNext(shouldSpin) {
this.verificationStatus = AuthPromptStatus.VERIFICATION_IN_PROGRESS;
this.updateSensitivity(false);
if (shouldSpin)
this.startSpinning();
if (this._queryingService)
this._userVerifier.answerQuery(this._queryingService, this._entry.text);
else
this._preemptiveAnswer = this._entry.text;
this.emit('next');
}
_updateEntry(secret) {
if (secret && this._entry !== this._passwordEntry) {
this._mainBox.replace_child(this._entry, this._passwordEntry);
this._entry = this._passwordEntry;
this._inactiveEntry = this._textEntry;
} else if (!secret && this._entry !== this._textEntry) {
this._mainBox.replace_child(this._entry, this._textEntry);
this._entry = this._textEntry;
this._inactiveEntry = this._passwordEntry;
}
this._capsLockWarningLabel.visible = secret;
}
_onAskQuestion(verifier, serviceName, question, secret) {
if (this._queryingService)
this.clear();
this._queryingService = serviceName;
if (this._preemptiveAnswer) {
this._userVerifier.answerQuery(this._queryingService, this._preemptiveAnswer);
this._preemptiveAnswer = null;
return;
}
this._updateEntry(secret);
// Hack: The question string comes directly from PAM, if it's "Password:"
// we replace it with our own to allow localization, if it's something
// else we remove the last colon and any trailing or leading spaces.
if (question === 'Password:' || question === 'Password: ')
this.setQuestion(_('Password'));
else
this.setQuestion(question.replace(/[:] *$/, '').trim());
this.updateSensitivity(true);
this.emit('prompted');
}
_onShowChoiceList(userVerifier, serviceName, promptMessage, choiceList) {
if (this._queryingService)
this.clear();
this._queryingService = serviceName;
if (this._preemptiveAnswer)
this._preemptiveAnswer = null;
this.setChoiceList(promptMessage, choiceList);
this.updateSensitivity(true);
this.emit('prompted');
}
_onCredentialManagerAuthenticated() {
if (this.verificationStatus !== AuthPromptStatus.VERIFICATION_SUCCEEDED)
this.reset();
}
_onSmartcardStatusChanged() {
this.smartcardDetected = this._userVerifier.smartcardDetected;
// Most of the time we want to reset if the user inserts or removes
// a smartcard. Smartcard insertion "preempts" what the user was
// doing, and smartcard removal aborts the preemption.
// The exceptions are: 1) Don't reset on smartcard insertion if we're already verifying
// with a smartcard
// 2) Don't reset if we've already succeeded at verification and
// the user is getting logged in.
if (this._userVerifier.serviceIsDefault(GdmUtil.SMARTCARD_SERVICE_NAME) &&
(this.verificationStatus === AuthPromptStatus.VERIFYING ||
this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) &&
this.smartcardDetected)
return;
if (this.verificationStatus !== AuthPromptStatus.VERIFICATION_SUCCEEDED)
this.reset();
}
_onShowMessage(_userVerifier, serviceName, message, type) {
let wiggleParameters = {duration: 0};
if (type === GdmUtil.MessageType.ERROR &&
this._userVerifier.serviceIsFingerprint(serviceName)) {
// TODO: Use Await for wiggle to be over before unfreezing the user verifier queue
wiggleParameters = {
duration: 65,
wiggleCount: 3,
};
this._userVerifier.increaseCurrentMessageTimeout(
wiggleParameters.duration * (wiggleParameters.wiggleCount + 2));
}
this.setMessage(message, type, wiggleParameters);
this.emit('prompted');
}
_onVerificationFailed(userVerifier, serviceName, canRetry) {
const wasQueryingService = this._queryingService === serviceName;
if (wasQueryingService) {
this._queryingService = null;
this.clear();
}
this.updateSensitivity(canRetry);
this.setActorInDefaultButtonWell(null);
if (!canRetry)
this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED;
if (wasQueryingService)
wiggle(this._entry);
}
_onVerificationComplete() {
this.setActorInDefaultButtonWell(null);
this.verificationStatus = AuthPromptStatus.VERIFICATION_SUCCEEDED;
this.cancelButton.reactive = false;
this.cancelButton.can_focus = false;
}
_onReset() {
this.verificationStatus = AuthPromptStatus.NOT_VERIFYING;
this.reset();
}
setActorInDefaultButtonWell(actor, animate) {
if (!this._defaultButtonWellActor &&
!actor)
return;
let oldActor = this._defaultButtonWellActor;
if (oldActor)
oldActor.remove_all_transitions();
let wasSpinner;
if (oldActor === this._spinner)
wasSpinner = true;
else
wasSpinner = false;
let isSpinner;
if (actor === this._spinner)
isSpinner = true;
else
isSpinner = false;
if (this._defaultButtonWellActor !== actor && oldActor) {
if (!animate) {
oldActor.opacity = 0;
if (wasSpinner) {
if (this._spinner)
this._spinner.stop();
}
} else {
oldActor.ease({
opacity: 0,
duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME,
delay: DEFAULT_BUTTON_WELL_ANIMATION_DELAY,
mode: Clutter.AnimationMode.LINEAR,
onComplete: () => {
if (wasSpinner) {
if (this._spinner)
this._spinner.stop();
}
},
});
}
}
if (actor) {
if (isSpinner)
this._spinner.play();
if (!animate) {
actor.opacity = 255;
} else {
actor.ease({
opacity: 255,
duration: DEFAULT_BUTTON_WELL_ANIMATION_TIME,
delay: DEFAULT_BUTTON_WELL_ANIMATION_DELAY,
mode: Clutter.AnimationMode.LINEAR,
});
}
}
this._defaultButtonWellActor = actor;
}
startSpinning() {
this.setActorInDefaultButtonWell(this._spinner, true);
}
stopSpinning() {
this.setActorInDefaultButtonWell(null, false);
}
clear() {
this._entry.text = '';
this.stopSpinning();
this._authList.clear();
this._authList.hide();
}
setQuestion(question) {
this._entry.hint_text = question;
this._authList.hide();
this._entry.show();
this._entry.grab_key_focus();
}
_fadeInChoiceList() {
this._authList.set({
opacity: 0,
visible: true,
reactive: false,
});
this._authList.ease({
opacity: 255,
duration: MESSAGE_FADE_OUT_ANIMATION_TIME,
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => (this._authList.reactive = true),
});
}
setChoiceList(promptMessage, choiceList) {
this._authList.clear();
this._authList.label.text = promptMessage;
for (let key in choiceList) {
let text = choiceList[key];
this._authList.addItem(key, text);
}
this._entry.hide();
if (this._message.text === '')
this._message.hide();
this._fadeInChoiceList();
}
getAnswer() {
let text;
if (this._preemptiveAnswer) {
text = this._preemptiveAnswer;
this._preemptiveAnswer = null;
} else {
text = this._entry.get_text();
}
return text;
}
_fadeOutMessage() {
if (this._message.opacity === 0)
return;
this._message.remove_all_transitions();
this._message.ease({
opacity: 0,
duration: MESSAGE_FADE_OUT_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
setMessage(message, type, wiggleParameters = {duration: 0}) {
if (type === GdmUtil.MessageType.ERROR)
this._message.add_style_class_name('login-dialog-message-warning');
else
this._message.remove_style_class_name('login-dialog-message-warning');
if (type === GdmUtil.MessageType.HINT)
this._message.add_style_class_name('login-dialog-message-hint');
else
this._message.remove_style_class_name('login-dialog-message-hint');
this._message.show();
if (message) {
this._message.remove_all_transitions();
this._message.text = message;
this._message.opacity = 255;
} else {
this._message.opacity = 0;
}
wiggle(this._message, wiggleParameters);
}
updateSensitivity(sensitive) {
if (this._entry.reactive === sensitive)
return;
this._entry.reactive = sensitive;
if (sensitive) {
this._entry.grab_key_focus();
} else {
this.grab_key_focus();
if (this._entry === this._passwordEntry)
this._entry.password_visible = false;
}
}
vfunc_hide() {
this.setActorInDefaultButtonWell(null, true);
super.vfunc_hide();
this._message.opacity = 0;
this.setUser(null);
this.updateSensitivity(true);
this._entry.set_text('');
}
setUser(user) {
let oldChild = this._userWell.get_child();
if (oldChild)
oldChild.destroy();
let userWidget = new UserWidget.UserWidget(user, Clutter.Orientation.VERTICAL);
this._userWell.set_child(userWidget);
if (!user)
this._updateEntry(false);
}
reset() {
let oldStatus = this.verificationStatus;
this.verificationStatus = AuthPromptStatus.NOT_VERIFYING;
this.cancelButton.reactive = this._hasCancelButton;
this.cancelButton.can_focus = this._hasCancelButton;
this._preemptiveAnswer = null;
if (this._userVerifier)
this._userVerifier.cancel();
this._queryingService = null;
this.clear();
this._message.opacity = 0;
this.setUser(null);
this._updateEntry(true);
this.stopSpinning();
if (oldStatus === AuthPromptStatus.VERIFICATION_FAILED)
this.emit('failed');
else if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED)
this.emit('cancelled');
let beginRequestType;
if (this._mode === AuthPromptMode.UNLOCK_ONLY) {
// The user is constant at the unlock screen, so it will immediately
// respond to the request with the username
if (oldStatus === AuthPromptStatus.VERIFICATION_CANCELLED)
return;
beginRequestType = BeginRequestType.PROVIDE_USERNAME;
} else if (this._userVerifier.foregroundServiceDeterminesUsername()) {
// We don't need to know the username if the user preempted the login screen
// with a smartcard or with preauthenticated oVirt credentials
beginRequestType = BeginRequestType.DONT_PROVIDE_USERNAME;
} else if (oldStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) {
// We're going back to retry with current user
beginRequestType = BeginRequestType.REUSE_USERNAME;
} else {
// In all other cases, we should get the username up front.
beginRequestType = BeginRequestType.PROVIDE_USERNAME;
}
this.emit('reset', beginRequestType);
}
addCharacter(unichar) {
if (!this._entry.visible)
return;
this._entry.grab_key_focus();
this._entry.clutter_text.insert_unichar(unichar);
}
begin(params) {
params = Params.parse(params, {
userName: null,
hold: null,
});
this.updateSensitivity(false);
let hold = params.hold;
if (!hold)
hold = new Batch.Hold();
this._userVerifier.begin(params.userName, hold);
this.verificationStatus = AuthPromptStatus.VERIFYING;
}
finish(onComplete) {
if (!this._userVerifier.hasPendingMessages) {
this._userVerifier.clear();
onComplete();
return;
}
let signalId = this._userVerifier.connect('no-more-messages', () => {
this._userVerifier.disconnect(signalId);
this._userVerifier.clear();
onComplete();
});
}
cancel() {
if (this.verificationStatus === AuthPromptStatus.VERIFICATION_SUCCEEDED)
return;
if (this.verificationStatus === AuthPromptStatus.VERIFICATION_IN_PROGRESS) {
this._cancelledRetries++;
if (this._cancelledRetries > this._userVerifier.allowedFailures)
this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED;
} else {
this.verificationStatus = AuthPromptStatus.VERIFICATION_CANCELLED;
}
this.reset();
}
});

204
js/gdm/batch.js Normal file
View file

@ -0,0 +1,204 @@
/*
* Copyright 2011 Red Hat, Inc
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, see <http://www.gnu.org/licenses/>.
*/
/*
* In order for transformation animations to look good, they need to be
* incremental and have some order to them (e.g., fade out hidden items,
* then shrink to close the void left over). Chaining animations in this way can
* be error-prone and wordy using just ease() callbacks.
*
* The classes in this file help with this:
*
* - Task. encapsulates schedulable work to be run in a specific scope.
*
* - ConsecutiveBatch. runs a series of tasks in order and completes
* when the last in the series finishes.
*
* - ConcurrentBatch. runs a set of tasks at the same time and completes
* when the last to finish completes.
*
* - Hold. prevents a batch from completing the pending task until
* the hold is released.
*
* The tasks associated with a batch are specified in a list at batch
* construction time as either task objects or plain functions.
* Batches are task objects, themselves, so they can be nested.
*
* These classes aren't specific to GDM, but were found to be unintuitive and so
* are not used elsewhere. These APIs may ultimately get dropped entirely and
* replaced by something else.
*/
import GObject from 'gi://GObject';
import * as Signals from '../misc/signals.js';
export class Task extends Signals.EventEmitter {
constructor(scope, handler) {
super();
if (scope)
this.scope = scope;
else
this.scope = this;
this.handler = handler;
}
run() {
if (this.handler)
return this.handler.call(this.scope);
return null;
}
}
export class Hold extends Task {
constructor() {
super(null, () => this);
this._acquisitions = 1;
}
acquire() {
if (this._acquisitions <= 0)
throw new Error("Cannot acquire hold after it's been released");
this._acquisitions++;
}
acquireUntilAfter(hold) {
if (!hold.isAcquired())
return;
this.acquire();
let signalId = hold.connect('release', () => {
hold.disconnect(signalId);
this.release();
});
}
release() {
this._acquisitions--;
if (this._acquisitions === 0)
this.emit('release');
}
isAcquired() {
return this._acquisitions > 0;
}
}
export class Batch extends Task {
constructor(scope, tasks) {
super();
this.tasks = [];
for (let i = 0; i < tasks.length; i++) {
let task;
if (tasks[i] instanceof Task)
task = tasks[i];
else if (typeof tasks[i] == 'function')
task = new Task(scope, tasks[i]);
else
throw new Error('Batch tasks must be functions or Task, Hold or Batch objects');
this.tasks.push(task);
}
}
process() {
throw new GObject.NotImplementedError(`process in ${this.constructor.name}`);
}
runTask() {
if (!(this._currentTaskIndex in this.tasks))
return null;
return this.tasks[this._currentTaskIndex].run();
}
_finish() {
this.hold.release();
}
nextTask() {
this._currentTaskIndex++;
// if the entire batch of tasks is finished, release
// the hold and notify anyone waiting on the batch
if (this._currentTaskIndex >= this.tasks.length) {
this._finish();
return;
}
this.process();
}
_start() {
// acquire a hold to get released when the entire
// batch of tasks is finished
this.hold = new Hold();
this._currentTaskIndex = 0;
this.process();
}
run() {
this._start();
// hold may be destroyed at this point
// if we're already done running
return this.hold;
}
cancel() {
this.tasks = this.tasks.splice(0, this._currentTaskIndex + 1);
}
}
export class ConcurrentBatch extends Batch {
process() {
let hold = this.runTask();
if (hold)
this.hold.acquireUntilAfter(hold);
// Regardless of the state of the just run task,
// fire off the next one, so all the tasks can run
// concurrently.
this.nextTask();
}
}
export class ConsecutiveBatch extends Batch {
process() {
let hold = this.runTask();
if (hold && hold.isAcquired()) {
// This task is inhibiting the batch. Wait on it
// before processing the next one.
let signalId = hold.connect('release', () => {
hold.disconnect(signalId);
this.nextTask();
});
} else {
// This task finished, process the next one
this.nextTask();
}
}
}

View file

@ -0,0 +1,24 @@
import * as Signals from '../misc/signals.js';
export class CredentialManager extends Signals.EventEmitter {
constructor(service) {
super();
this._token = null;
this._service = service;
}
get token() {
return this._token;
}
set token(t) {
this._token = t;
if (this._token)
this.emit('user-authenticated', this._token);
}
get service() {
return this._service;
}
}

1531
js/gdm/loginDialog.js Normal file

File diff suppressed because it is too large Load diff

51
js/gdm/oVirt.js Normal file
View file

@ -0,0 +1,51 @@
import Gio from 'gi://Gio';
import * as Credential from './credentialManager.js';
export const SERVICE_NAME = 'gdm-ovirtcred';
const OVirtCredentialsIface = `
<node>
<interface name="org.ovirt.vdsm.Credentials">
<signal name="UserAuthenticated">
<arg type="s" name="token"/>
</signal>
</interface>
</node>`;
const OVirtCredentialsInfo = Gio.DBusInterfaceInfo.new_for_xml(OVirtCredentialsIface);
let _oVirtCredentialsManager = null;
function OVirtCredentials() {
var self = new Gio.DBusProxy({
g_connection: Gio.DBus.system,
g_interface_name: OVirtCredentialsInfo.name,
g_interface_info: OVirtCredentialsInfo,
g_name: 'org.ovirt.vdsm.Credentials',
g_object_path: '/org/ovirt/vdsm/Credentials',
g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES,
});
self.init(null);
return self;
}
class OVirtCredentialsManager extends Credential.CredentialManager {
constructor() {
super(SERVICE_NAME);
this._credentials = new OVirtCredentials();
this._credentials.connectSignal('UserAuthenticated',
(proxy, sender, [token]) => {
this.token = token;
});
}
}
/**
* @returns {OVirtCredentialsManager}
*/
export function getOVirtCredentialsManager() {
if (!_oVirtCredentialsManager)
_oVirtCredentialsManager = new OVirtCredentialsManager();
return _oVirtCredentialsManager;
}

109
js/gdm/realmd.js Normal file
View file

@ -0,0 +1,109 @@
import Gio from 'gi://Gio';
import * as Signals from '../misc/signals.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const ProviderIface = loadInterfaceXML('org.freedesktop.realmd.Provider');
const Provider = Gio.DBusProxy.makeProxyWrapper(ProviderIface);
const ServiceIface = loadInterfaceXML('org.freedesktop.realmd.Service');
const Service = Gio.DBusProxy.makeProxyWrapper(ServiceIface);
const RealmIface = loadInterfaceXML('org.freedesktop.realmd.Realm');
const Realm = Gio.DBusProxy.makeProxyWrapper(RealmIface);
export class Manager extends Signals.EventEmitter {
constructor() {
super();
this._aggregateProvider = Provider(Gio.DBus.system,
'org.freedesktop.realmd',
'/org/freedesktop/realmd',
this._reloadRealms.bind(this));
this._realms = {};
this._loginFormat = null;
this._aggregateProvider.connectObject('g-properties-changed',
(proxy, properties) => {
const realmsChanged = !!properties.lookup_value('Realms', null);
if (realmsChanged)
this._reloadRealms();
}, this);
}
_reloadRealms() {
let realmPaths = this._aggregateProvider.Realms;
if (!realmPaths)
return;
for (let i = 0; i < realmPaths.length; i++) {
Realm(Gio.DBus.system,
'org.freedesktop.realmd',
realmPaths[i],
this._onRealmLoaded.bind(this));
}
}
_reloadRealm(realm) {
if (!realm.Configured) {
if (this._realms[realm.get_object_path()])
delete this._realms[realm.get_object_path()];
return;
}
this._realms[realm.get_object_path()] = realm;
this._updateLoginFormat();
}
_onRealmLoaded(realm, error) {
if (error)
return;
this._reloadRealm(realm);
realm.connect('g-properties-changed', (proxy, properties) => {
const configuredChanged = !!properties.lookup_value('Configured', null);
if (configuredChanged)
this._reloadRealm(realm);
});
}
_updateLoginFormat() {
let newLoginFormat;
for (let realmPath in this._realms) {
let realm = this._realms[realmPath];
if (realm.LoginFormats && realm.LoginFormats.length > 0) {
newLoginFormat = realm.LoginFormats[0];
break;
}
}
if (this._loginFormat !== newLoginFormat) {
this._loginFormat = newLoginFormat;
this.emit('login-format-changed', newLoginFormat);
}
}
get loginFormat() {
if (this._loginFormat)
return this._loginFormat;
this._updateLoginFormat();
return this._loginFormat;
}
release() {
Service(Gio.DBus.system,
'org.freedesktop.realmd',
'/org/freedesktop/realmd',
service => service.ReleaseAsync().catch(logError));
this._aggregateProvider.disconnectObject(this);
this._realms = { };
this._updateLoginFormat();
}
}

924
js/gdm/util.js Normal file
View file

@ -0,0 +1,924 @@
import Clutter from 'gi://Clutter';
import Gdm from 'gi://Gdm';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as Signals from '../misc/signals.js';
import * as Batch from './batch.js';
import * as OVirt from './oVirt.js';
import * as Vmware from './vmware.js';
import * as Main from '../ui/main.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
import * as Params from '../misc/params.js';
import * as SmartcardManager from '../misc/smartcardManager.js';
const FprintManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(
loadInterfaceXML('net.reactivated.Fprint.Manager'));
const FprintDeviceInfo = Gio.DBusInterfaceInfo.new_for_xml(
loadInterfaceXML('net.reactivated.Fprint.Device'));
Gio._promisify(Gdm.Client.prototype, 'open_reauthentication_channel');
Gio._promisify(Gdm.Client.prototype, 'get_user_verifier');
Gio._promisify(Gdm.UserVerifierProxy.prototype,
'call_begin_verification_for_user');
Gio._promisify(Gdm.UserVerifierProxy.prototype, 'call_begin_verification');
export const PASSWORD_SERVICE_NAME = 'gdm-password';
export const FINGERPRINT_SERVICE_NAME = 'gdm-fingerprint';
export const SMARTCARD_SERVICE_NAME = 'gdm-smartcard';
const CLONE_FADE_ANIMATION_TIME = 250;
export const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen';
export const PASSWORD_AUTHENTICATION_KEY = 'enable-password-authentication';
export const FINGERPRINT_AUTHENTICATION_KEY = 'enable-fingerprint-authentication';
export const SMARTCARD_AUTHENTICATION_KEY = 'enable-smartcard-authentication';
export const BANNER_MESSAGE_KEY = 'banner-message-enable';
export const BANNER_MESSAGE_SOURCE_KEY = 'banner-message-source';
export const BANNER_MESSAGE_TEXT_KEY = 'banner-message-text';
export const BANNER_MESSAGE_PATH_KEY = 'banner-message-path';
export const ALLOWED_FAILURES_KEY = 'allowed-failures';
export const LOGO_KEY = 'logo';
export const DISABLE_USER_LIST_KEY = 'disable-user-list';
// Give user 48ms to read each character of a PAM message
const USER_READ_TIME = 48;
const FINGERPRINT_SERVICE_PROXY_TIMEOUT = 5000;
const FINGERPRINT_ERROR_TIMEOUT_WAIT = 15;
/**
* Keep messages in order by priority
*
* @enum {number}
*/
export const MessageType = {
NONE: 0,
HINT: 1,
INFO: 2,
ERROR: 3,
};
const FingerprintReaderType = {
NONE: 0,
PRESS: 1,
SWIPE: 2,
};
/**
* @param {Clutter.Actor} actor
*/
export function cloneAndFadeOutActor(actor) {
// Immediately hide actor so its sibling can have its space
// and position, but leave a non-reactive clone on-screen,
// so from the user's point of view it smoothly fades away
// and reveals its sibling.
actor.hide();
const clone = new Clutter.Clone({
source: actor,
reactive: false,
});
Main.uiGroup.add_child(clone);
let [x, y] = actor.get_transformed_position();
clone.set_position(x, y);
let hold = new Batch.Hold();
clone.ease({
opacity: 0,
duration: CLONE_FADE_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
clone.destroy();
hold.release();
},
});
return hold;
}
export class ShellUserVerifier extends Signals.EventEmitter {
constructor(client, params) {
super();
params = Params.parse(params, {reauthenticationOnly: false});
this._reauthOnly = params.reauthenticationOnly;
this._client = client;
this._cancellable = null;
this._defaultService = null;
this._preemptingService = null;
this._fingerprintReaderType = FingerprintReaderType.NONE;
this._messageQueue = [];
this._messageQueueTimeoutId = 0;
this._failCounter = 0;
this._activeServices = new Set();
this._unavailableServices = new Set();
this._credentialManagers = {};
this.reauthenticating = false;
this.smartcardDetected = false;
this._settings = new Gio.Settings({schema_id: LOGIN_SCREEN_SCHEMA});
this._settings.connect('changed', () => this._onSettingsChanged());
this._updateEnabledServices();
this._updateDefaultService();
this.addCredentialManager(OVirt.SERVICE_NAME, OVirt.getOVirtCredentialsManager());
this.addCredentialManager(Vmware.SERVICE_NAME, Vmware.getVmwareCredentialsManager());
}
addCredentialManager(serviceName, credentialManager) {
if (this._credentialManagers[serviceName])
return;
this._credentialManagers[serviceName] = credentialManager;
if (credentialManager.token) {
this._onCredentialManagerAuthenticated(credentialManager,
credentialManager.token);
}
credentialManager.connectObject('user-authenticated',
this._onCredentialManagerAuthenticated.bind(this), this);
}
removeCredentialManager(serviceName) {
let credentialManager = this._credentialManagers[serviceName];
if (!credentialManager)
return;
credentialManager.disconnectObject(this);
delete this._credentialManagers[serviceName];
}
get hasPendingMessages() {
return !!this._messageQueue.length;
}
get allowedFailures() {
return this._settings.get_int(ALLOWED_FAILURES_KEY);
}
get currentMessage() {
return this._messageQueue ? this._messageQueue[0] : null;
}
begin(userName, hold) {
this._cancellable = new Gio.Cancellable();
this._hold = hold;
this._userName = userName;
this.reauthenticating = false;
this._checkForFingerprintReader().catch(e =>
this._handleFingerprintError(e));
// If possible, reauthenticate an already running session,
// so any session specific credentials get updated appropriately
if (userName)
this._openReauthenticationChannel(userName);
else
this._getUserVerifier();
}
cancel() {
if (this._cancellable)
this._cancellable.cancel();
if (this._userVerifier) {
this._userVerifier.call_cancel_sync(null);
this.clear();
}
}
_clearUserVerifier() {
if (this._userVerifier) {
this._disconnectSignals();
this._userVerifier.run_dispose();
this._userVerifier = null;
if (this._userVerifierChoiceList) {
this._userVerifierChoiceList.run_dispose();
this._userVerifierChoiceList = null;
}
}
}
clear() {
if (this._cancellable) {
this._cancellable.cancel();
this._cancellable = null;
}
this._clearUserVerifier();
this._clearMessageQueue();
this._activeServices.clear();
}
destroy() {
this.cancel();
this._settings.run_dispose();
this._settings = null;
this._smartcardManager?.disconnectObject(this);
this._smartcardManager = null;
this._fingerprintManager = null;
for (let service in this._credentialManagers)
this.removeCredentialManager(service);
}
selectChoice(serviceName, key) {
this._userVerifierChoiceList.call_select_choice(serviceName, key, this._cancellable, null);
}
async answerQuery(serviceName, answer) {
try {
await this._handlePendingMessages();
this._userVerifier.call_answer_query(serviceName, answer, this._cancellable, null);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
_getIntervalForMessage(message) {
if (!message)
return 0;
// We probably could be smarter here
return message.length * USER_READ_TIME;
}
finishMessageQueue() {
if (!this.hasPendingMessages)
return;
this._messageQueue = [];
this.emit('no-more-messages');
}
increaseCurrentMessageTimeout(interval) {
if (!this._messageQueueTimeoutId && interval > 0)
this._currentMessageExtraInterval = interval;
}
_serviceHasPendingMessages(serviceName) {
return this._messageQueue.some(m => m.serviceName === serviceName);
}
_filterServiceMessages(serviceName, messageType) {
// This function allows to remove queued messages for the @serviceName
// whose type has lower priority than @messageType, replacing them
// with a null message that will lead to clearing the prompt once done.
if (this._serviceHasPendingMessages(serviceName))
this._queuePriorityMessage(serviceName, null, messageType);
}
_queueMessageTimeout() {
if (this._messageQueueTimeoutId !== 0)
return;
const message = this.currentMessage;
delete this._currentMessageExtraInterval;
this.emit('show-message', message.serviceName, message.text, message.type);
this._messageQueueTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
message.interval + (this._currentMessageExtraInterval | 0), () => {
this._messageQueueTimeoutId = 0;
if (this._messageQueue.length > 1) {
this._messageQueue.shift();
this._queueMessageTimeout();
} else {
this.finishMessageQueue();
}
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._messageQueueTimeoutId, '[gnome-shell] this._queueMessageTimeout');
}
_queueMessage(serviceName, message, messageType) {
let interval = this._getIntervalForMessage(message);
this._messageQueue.push({serviceName, text: message, type: messageType, interval});
this._queueMessageTimeout();
}
_queuePriorityMessage(serviceName, message, messageType) {
const newQueue = this._messageQueue.filter(m => {
if (m.serviceName !== serviceName || m.type >= messageType)
return m.text !== message;
return false;
});
if (!newQueue.includes(this.currentMessage))
this._clearMessageQueue();
this._messageQueue = newQueue;
this._queueMessage(serviceName, message, messageType);
}
_clearMessageQueue() {
this.finishMessageQueue();
if (this._messageQueueTimeoutId !== 0) {
GLib.source_remove(this._messageQueueTimeoutId);
this._messageQueueTimeoutId = 0;
}
this.emit('show-message', null, null, MessageType.NONE);
}
async _initFingerprintManager() {
if (this._fprintManager)
return;
const fprintManager = new Gio.DBusProxy({
g_connection: Gio.DBus.system,
g_name: 'net.reactivated.Fprint',
g_object_path: '/net/reactivated/Fprint/Manager',
g_interface_name: FprintManagerInfo.name,
g_interface_info: FprintManagerInfo,
g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES |
Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION |
Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS,
});
try {
if (!this._getDetectedDefaultService()) {
// Other authentication methods would have already been detected by
// now as possibilities if they were available.
// If we're here it means that FINGERPRINT_AUTHENTICATION_KEY is
// true and so fingerprint authentication is our last potential
// option, so go ahead a synchronously look for a fingerprint device
// during startup or default service update.
fprintManager.init(null);
// Do not wait too much for fprintd to reply, as in case it hangs
// we should fail early without having the shell to misbehave
fprintManager.set_default_timeout(FINGERPRINT_SERVICE_PROXY_TIMEOUT);
const [devicePath] = fprintManager.GetDefaultDeviceSync();
this._fprintManager = fprintManager;
const fprintDeviceProxy = this._getFingerprintDeviceProxy(devicePath);
fprintDeviceProxy.init(null);
this._setFingerprintReaderType(fprintDeviceProxy['scan-type']);
} else {
// Ensure fingerprint service starts, but do not wait for it
const cancellable = this._cancellable;
await fprintManager.init_async(GLib.PRIORITY_DEFAULT, cancellable);
await this._updateFingerprintReaderType(fprintManager, cancellable);
this._fprintManager = fprintManager;
}
} catch (e) {
this._handleFingerprintError(e);
}
}
_getFingerprintDeviceProxy(devicePath) {
return new Gio.DBusProxy({
g_connection: Gio.DBus.system,
g_name: 'net.reactivated.Fprint',
g_object_path: devicePath,
g_interface_name: FprintDeviceInfo.name,
g_interface_info: FprintDeviceInfo,
g_flags: Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS,
});
}
_handleFingerprintError(e) {
this._fingerprintReaderType = FingerprintReaderType.NONE;
if (e instanceof GLib.Error) {
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
return;
if (e.matches(Gio.DBusError, Gio.DBusError.SERVICE_UNKNOWN))
return;
if (Gio.DBusError.is_remote_error(e) &&
Gio.DBusError.get_remote_error(e) ===
'net.reactivated.Fprint.Error.NoSuchDevice')
return;
}
logError(e, 'Failed to interact with fprintd service');
}
async _checkForFingerprintReader() {
if (!this._fprintManager) {
this._updateDefaultService();
return;
}
if (this._fingerprintReaderType !== FingerprintReaderType.NONE)
return;
await this._updateFingerprintReaderType(this._fprintManager, this._cancellable);
}
async _updateFingerprintReaderType(fprintManager, cancellable) {
// Wrappers don't support null cancellable, so let's ignore it in case
const args = cancellable ? [cancellable] : [];
const [devicePath] = await fprintManager.GetDefaultDeviceAsync(...args);
const fprintDeviceProxy = this._getFingerprintDeviceProxy(devicePath);
await fprintDeviceProxy.init_async(GLib.PRIORITY_DEFAULT, cancellable);
this._setFingerprintReaderType(fprintDeviceProxy['scan-type']);
this._updateDefaultService();
if (this._userVerifier &&
!this._activeServices.has(FINGERPRINT_SERVICE_NAME)) {
if (!this._hold?.isAcquired())
this._hold = new Batch.Hold();
await this._maybeStartFingerprintVerification();
}
}
_setFingerprintReaderType(fprintDeviceType) {
this._fingerprintReaderType =
FingerprintReaderType[fprintDeviceType.toUpperCase()];
if (this._fingerprintReaderType === undefined)
throw new Error(`Unexpected fingerprint device type '${fprintDeviceType}'`);
}
_onCredentialManagerAuthenticated(credentialManager, _token) {
this._preemptingService = credentialManager.service;
this.emit('credential-manager-authenticated');
}
_initSmartcardManager() {
if (this._smartcardManager)
return;
this._smartcardManager = SmartcardManager.getSmartcardManager();
// We check for smartcards right away, since an inserted smartcard
// at startup should result in immediately initiating authentication.
// This is different than fingerprint readers, where we only check them
// after a user has been picked.
this.smartcardDetected = false;
this._checkForSmartcard();
this._smartcardManager.connectObject(
'smartcard-inserted', () => this._checkForSmartcard(),
'smartcard-removed', () => this._checkForSmartcard(), this);
}
_checkForSmartcard() {
let smartcardDetected;
if (!this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY))
smartcardDetected = false;
else if (this._reauthOnly)
smartcardDetected = this._smartcardManager.hasInsertedLoginToken();
else
smartcardDetected = this._smartcardManager.hasInsertedTokens();
if (smartcardDetected !== this.smartcardDetected) {
this.smartcardDetected = smartcardDetected;
if (this.smartcardDetected)
this._preemptingService = SMARTCARD_SERVICE_NAME;
else if (this._preemptingService === SMARTCARD_SERVICE_NAME)
this._preemptingService = null;
this.emit('smartcard-status-changed');
}
}
_reportInitError(where, error, serviceName) {
logError(error, where);
this._hold.release();
this._queueMessage(serviceName, _('Authentication error'), MessageType.ERROR);
this._failCounter++;
this._verificationFailed(serviceName, false);
}
async _openReauthenticationChannel(userName) {
try {
this._clearUserVerifier();
this._userVerifier = await this._client.open_reauthentication_channel(
userName, this._cancellable);
} catch (e) {
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
return;
if (e.matches(Gio.DBusError, Gio.DBusError.ACCESS_DENIED) &&
!this._reauthOnly) {
// Gdm emits org.freedesktop.DBus.Error.AccessDenied when there
// is no session to reauthenticate. Fall back to performing
// verification from this login session
this._getUserVerifier();
return;
}
this._reportInitError('Failed to open reauthentication channel', e);
return;
}
if (this._client.get_user_verifier_choice_list)
this._userVerifierChoiceList = this._client.get_user_verifier_choice_list();
else
this._userVerifierChoiceList = null;
this.reauthenticating = true;
this._connectSignals();
this._beginVerification();
this._hold.release();
}
async _getUserVerifier() {
try {
this._clearUserVerifier();
this._userVerifier =
await this._client.get_user_verifier(this._cancellable);
} catch (e) {
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
return;
this._reportInitError('Failed to obtain user verifier', e);
return;
}
if (this._client.get_user_verifier_choice_list)
this._userVerifierChoiceList = this._client.get_user_verifier_choice_list();
else
this._userVerifierChoiceList = null;
this._connectSignals();
this._beginVerification();
this._hold.release();
}
_connectSignals() {
this._disconnectSignals();
this._userVerifier.connectObject(
'info', this._onInfo.bind(this),
'problem', this._onProblem.bind(this),
'info-query', this._onInfoQuery.bind(this),
'secret-info-query', this._onSecretInfoQuery.bind(this),
'conversation-started', this._onConversationStarted.bind(this),
'conversation-stopped', this._onConversationStopped.bind(this),
'service-unavailable', this._onServiceUnavailable.bind(this),
'reset', this._onReset.bind(this),
'verification-complete', this._onVerificationComplete.bind(this),
this);
if (this._userVerifierChoiceList) {
this._userVerifierChoiceList.connectObject('choice-query',
this._onChoiceListQuery.bind(this), this);
}
}
_disconnectSignals() {
this._userVerifier?.disconnectObject(this);
this._userVerifierChoiceList?.disconnectObject(this);
}
_getForegroundService() {
if (this._preemptingService)
return this._preemptingService;
return this._defaultService;
}
serviceIsForeground(serviceName) {
return serviceName === this._getForegroundService();
}
foregroundServiceDeterminesUsername() {
for (let serviceName in this._credentialManagers) {
if (this.serviceIsForeground(serviceName))
return true;
}
return this.serviceIsForeground(SMARTCARD_SERVICE_NAME);
}
serviceIsDefault(serviceName) {
return serviceName === this._defaultService;
}
serviceIsFingerprint(serviceName) {
return this._fingerprintReaderType !== FingerprintReaderType.NONE &&
serviceName === FINGERPRINT_SERVICE_NAME;
}
_onSettingsChanged() {
this._updateEnabledServices();
this._updateDefaultService();
}
_updateEnabledServices() {
let needsReset = false;
if (this._settings.get_boolean(FINGERPRINT_AUTHENTICATION_KEY)) {
this._initFingerprintManager().catch(logError);
} else if (this._fingerprintManager) {
this._fingerprintManager = null;
this._fingerprintReaderType = FingerprintReaderType.NONE;
if (this._activeServices.has(FINGERPRINT_SERVICE_NAME))
needsReset = true;
}
if (this._settings.get_boolean(SMARTCARD_AUTHENTICATION_KEY)) {
this._initSmartcardManager();
} else if (this._smartcardManager) {
this._smartcardManager.disconnectObject(this);
this._smartcardManager = null;
if (this._activeServices.has(SMARTCARD_SERVICE_NAME))
needsReset = true;
}
if (needsReset)
this._cancelAndReset();
}
_getDetectedDefaultService() {
if (this._settings.get_boolean(PASSWORD_AUTHENTICATION_KEY))
return PASSWORD_SERVICE_NAME;
else if (this._smartcardManager)
return SMARTCARD_SERVICE_NAME;
else if (this._fingerprintReaderType !== FingerprintReaderType.NONE)
return FINGERPRINT_SERVICE_NAME;
return null;
}
_updateDefaultService() {
const oldDefaultService = this._defaultService;
this._defaultService = this._getDetectedDefaultService();
if (!this._defaultService) {
log('no authentication service is enabled, using password authentication');
this._defaultService = PASSWORD_SERVICE_NAME;
}
if (oldDefaultService &&
oldDefaultService !== this._defaultService &&
this._activeServices.has(oldDefaultService))
this._cancelAndReset();
}
async _startService(serviceName) {
this._hold.acquire();
try {
this._activeServices.add(serviceName);
if (this._userName) {
await this._userVerifier.call_begin_verification_for_user(
serviceName, this._userName, this._cancellable);
} else {
await this._userVerifier.call_begin_verification(
serviceName, this._cancellable);
}
} catch (e) {
this._activeServices.delete(serviceName);
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
return;
if (!this.serviceIsForeground(serviceName)) {
logError(e,
`Failed to start ${serviceName} for ${this._userName}`);
this._hold.release();
return;
}
this._reportInitError(
this._userName
? `Failed to start ${serviceName} verification for user`
: `Failed to start ${serviceName} verification`,
e, serviceName);
return;
}
this._hold.release();
}
_beginVerification() {
this._startService(this._getForegroundService());
this._maybeStartFingerprintVerification().catch(logError);
}
async _maybeStartFingerprintVerification() {
if (this._userName &&
this._fingerprintReaderType !== FingerprintReaderType.NONE &&
!this.serviceIsForeground(FINGERPRINT_SERVICE_NAME))
await this._startService(FINGERPRINT_SERVICE_NAME);
}
_onChoiceListQuery(client, serviceName, promptMessage, list) {
if (!this.serviceIsForeground(serviceName))
return;
this.emit('show-choice-list', serviceName, promptMessage, list.deepUnpack());
}
_onInfo(client, serviceName, info) {
if (this.serviceIsForeground(serviceName)) {
this._queueMessage(serviceName, info, MessageType.INFO);
} else if (this.serviceIsFingerprint(serviceName)) {
// We don't show fingerprint messages directly since it's
// not the main auth service. Instead we use the messages
// as a cue to display our own message.
if (this._fingerprintReaderType === FingerprintReaderType.SWIPE) {
// Translators: this message is shown below the password entry field
// to indicate the user can swipe their finger on the fingerprint reader
this._queueMessage(serviceName, _('(or swipe finger across reader)'),
MessageType.HINT);
} else {
// Translators: this message is shown below the password entry field
// to indicate the user can place their finger on the fingerprint reader instead
this._queueMessage(serviceName, _('(or place finger on reader)'),
MessageType.HINT);
}
}
}
_onProblem(client, serviceName, problem) {
const isFingerprint = this.serviceIsFingerprint(serviceName);
if (!this.serviceIsForeground(serviceName) && !isFingerprint)
return;
this._queuePriorityMessage(serviceName, problem, MessageType.ERROR);
if (isFingerprint) {
// pam_fprintd allows the user to retry multiple (maybe even infinite!
// times before failing the authentication conversation.
// We don't want this behavior to bypass the max-tries setting the user has set,
// so we count the problem messages to know how many times the user has failed.
// Once we hit the max number of failures we allow, it's time to failure the
// conversation from our side. We can't do that right away, however, because
// we may drop pending messages coming from pam_fprintd. In order to make sure
// the user sees everything, we queue the failure up to get handled in the
// near future, after we've finished up the current round of messages.
this._failCounter++;
if (!this._canRetry()) {
if (this._fingerprintFailedId)
GLib.source_remove(this._fingerprintFailedId);
const cancellable = this._cancellable;
this._fingerprintFailedId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
FINGERPRINT_ERROR_TIMEOUT_WAIT, () => {
this._fingerprintFailedId = 0;
if (!cancellable.is_cancelled())
this._verificationFailed(serviceName, false);
return GLib.SOURCE_REMOVE;
});
}
}
}
_onInfoQuery(client, serviceName, question) {
if (!this.serviceIsForeground(serviceName))
return;
this.emit('ask-question', serviceName, question, false);
}
_onSecretInfoQuery(client, serviceName, secretQuestion) {
if (!this.serviceIsForeground(serviceName))
return;
let token = null;
if (this._credentialManagers[serviceName])
token = this._credentialManagers[serviceName].token;
if (token) {
this.answerQuery(serviceName, token);
return;
}
this.emit('ask-question', serviceName, secretQuestion, true);
}
_onReset() {
// Clear previous attempts to authenticate
this._failCounter = 0;
this._activeServices.clear();
this._unavailableServices.clear();
this._updateDefaultService();
this.emit('reset');
}
_onVerificationComplete(_client, serviceName) {
const isCredentialManager = !!this._credentialManagers[serviceName];
const isForeground = this.serviceIsForeground(serviceName);
if (isCredentialManager && isForeground) {
this._credentialManagers[serviceName].token = null;
this._preemptingService = null;
}
this.emit('verification-complete');
}
_cancelAndReset() {
this.cancel();
this._onReset();
}
_retry(serviceName) {
this._hold = new Batch.Hold();
this._connectSignals();
this._startService(serviceName);
}
_canRetry() {
return this._userName &&
(this._reauthOnly || this._failCounter < this.allowedFailures);
}
async _verificationFailed(serviceName, shouldRetry) {
if (serviceName === FINGERPRINT_SERVICE_NAME) {
if (this._fingerprintFailedId)
GLib.source_remove(this._fingerprintFailedId);
}
// For Not Listed / enterprise logins, immediately reset
// the dialog
// Otherwise, when in login mode we allow ALLOWED_FAILURES attempts.
// After that, we go back to the welcome screen.
this._filterServiceMessages(serviceName, MessageType.ERROR);
const doneTrying = !shouldRetry || !this._canRetry();
this.emit('verification-failed', serviceName, !doneTrying);
try {
if (doneTrying) {
this._disconnectSignals();
await this._handlePendingMessages();
this._cancelAndReset();
} else {
await this._handlePendingMessages();
this._retry(serviceName);
}
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
_handlePendingMessages() {
if (!this.hasPendingMessages)
return Promise.resolve();
const cancellable = this._cancellable;
return new Promise((resolve, reject) => {
let signalId = this.connect('no-more-messages', () => {
this.disconnect(signalId);
if (cancellable.is_cancelled())
reject(new GLib.Error(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, 'Operation was cancelled'));
else
resolve();
});
});
}
_onServiceUnavailable(_client, serviceName, errorMessage) {
this._unavailableServices.add(serviceName);
if (!errorMessage)
return;
if (this.serviceIsForeground(serviceName) || this.serviceIsFingerprint(serviceName))
this._queueMessage(serviceName, errorMessage, MessageType.ERROR);
}
_onConversationStarted(client, serviceName) {
this._activeServices.add(serviceName);
}
_onConversationStopped(client, serviceName) {
this._activeServices.delete(serviceName);
// If the login failed with the preauthenticated oVirt credentials
// then discard the credentials and revert to default authentication
// mechanism.
const isCredentialManager = !!this._credentialManagers[serviceName];
const isForeground = this.serviceIsForeground(serviceName);
if (isCredentialManager && isForeground) {
this._credentialManagers[serviceName].token = null;
this._preemptingService = null;
this._verificationFailed(serviceName, false);
return;
}
this._filterServiceMessages(serviceName, MessageType.ERROR);
if (this._unavailableServices.has(serviceName))
return;
// if the password service fails, then cancel everything.
// But if, e.g., fingerprint fails, still give
// password authentication a chance to succeed
if (isForeground)
this._failCounter++;
this._verificationFailed(serviceName, true);
}
}

54
js/gdm/vmware.js Normal file
View file

@ -0,0 +1,54 @@
import Gio from 'gi://Gio';
import * as Credential from './credentialManager.js';
const dbusPath = '/org/vmware/viewagent/Credentials';
const dbusInterface = 'org.vmware.viewagent.Credentials';
export const SERVICE_NAME = 'gdm-vmwcred';
const VmwareCredentialsIface = `<node>
<interface name="${dbusInterface}">
<signal name="UserAuthenticated">
<arg type="s" name="token"/>
</signal>
</interface>
</node>`;
const VmwareCredentialsInfo = Gio.DBusInterfaceInfo.new_for_xml(VmwareCredentialsIface);
let _vmwareCredentialsManager = null;
function VmwareCredentials() {
var self = new Gio.DBusProxy({
g_connection: Gio.DBus.session,
g_interface_name: VmwareCredentialsInfo.name,
g_interface_info: VmwareCredentialsInfo,
g_name: dbusInterface,
g_object_path: dbusPath,
g_flags: Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES,
});
self.init(null);
return self;
}
class VmwareCredentialsManager extends Credential.CredentialManager {
constructor() {
super(SERVICE_NAME);
this._credentials = new VmwareCredentials();
this._credentials.connectSignal('UserAuthenticated',
(proxy, sender, [token]) => {
this.token = token;
});
}
}
/**
* @returns {VmwareCredentialsManager}
*/
export function getVmwareCredentialsManager() {
if (!_vmwareCredentialsManager)
_vmwareCredentialsManager = new VmwareCredentialsManager();
return _vmwareCredentialsManager;
}

View file

@ -0,0 +1,161 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/shell">
<file>gdm/authList.js</file>
<file>gdm/authPrompt.js</file>
<file>gdm/batch.js</file>
<file>gdm/credentialManager.js</file>
<file>gdm/loginDialog.js</file>
<file>gdm/oVirt.js</file>
<file>gdm/realmd.js</file>
<file>gdm/util.js</file>
<file>gdm/vmware.js</file>
<file>extensions/extension.js</file>
<file>extensions/sharedInternals.js</file>
<file>misc/animationUtils.js</file>
<file>misc/breakManager.js</file>
<file>misc/config.js</file>
<file>misc/dateUtils.js</file>
<file>misc/dbusErrors.js</file>
<file>misc/dbusUtils.js</file>
<file>misc/dependencies.js</file>
<file>misc/errorUtils.js</file>
<file>misc/extensionUtils.js</file>
<file>misc/fileUtils.js</file>
<file>misc/gnomeSession.js</file>
<file>misc/history.js</file>
<file>misc/ibusManager.js</file>
<file>misc/inputMethod.js</file>
<file>misc/introspect.js</file>
<file>misc/jsParse.js</file>
<file>misc/keyboardManager.js</file>
<file>misc/loginManager.js</file>
<file>misc/modemManager.js</file>
<file>misc/objectManager.js</file>
<file>misc/params.js</file>
<file>misc/parentalControlsManager.js</file>
<file>misc/permissionStore.js</file>
<file>misc/signals.js</file>
<file>misc/signalTracker.js</file>
<file>misc/smartcardManager.js</file>
<file>misc/systemActions.js</file>
<file>misc/timeLimitsManager.js</file>
<file>misc/util.js</file>
<file>misc/weather.js</file>
<file>ui/accessDialog.js</file>
<file>ui/altTab.js</file>
<file>ui/animation.js</file>
<file>ui/appDisplay.js</file>
<file>ui/appFavorites.js</file>
<file>ui/appMenu.js</file>
<file>ui/audioDeviceSelection.js</file>
<file>ui/background.js</file>
<file>ui/backgroundMenu.js</file>
<file>ui/barLevel.js</file>
<file>ui/boxpointer.js</file>
<file>ui/calendar.js</file>
<file>ui/checkBox.js</file>
<file>ui/closeDialog.js</file>
<file>ui/ctrlAltTab.js</file>
<file>ui/dash.js</file>
<file>ui/dateMenu.js</file>
<file>ui/dialog.js</file>
<file>ui/dnd.js</file>
<file>ui/edgeDragAction.js</file>
<file>ui/endSessionDialog.js</file>
<file>ui/environment.js</file>
<file>ui/extensionDownloader.js</file>
<file>ui/extensionSystem.js</file>
<file>ui/focusCaretTracker.js</file>
<file>ui/grabHelper.js</file>
<file>ui/ibusCandidatePopup.js</file>
<file>ui/iconGrid.js</file>
<file>ui/inhibitShortcutsDialog.js</file>
<file>ui/init.js</file>
<file>ui/kbdA11yDialog.js</file>
<file>ui/keyboard.js</file>
<file>ui/layout.js</file>
<file>ui/lightbox.js</file>
<file>ui/listModes.js</file>
<file>ui/locatePointer.js</file>
<file>ui/lookingGlass.js</file>
<file>ui/magnifier.js</file>
<file>ui/main.js</file>
<file>ui/messageList.js</file>
<file>ui/messageTray.js</file>
<file>ui/modalDialog.js</file>
<file>ui/mpris.js</file>
<file>ui/notificationDaemon.js</file>
<file>ui/osdMonitorLabeler.js</file>
<file>ui/osdWindow.js</file>
<file>ui/overview.js</file>
<file>ui/overviewControls.js</file>
<file>ui/padOsd.js</file>
<file>ui/pageIndicators.js</file>
<file>ui/panel.js</file>
<file>ui/panelMenu.js</file>
<file>ui/pointerA11yTimeout.js</file>
<file>ui/pointerWatcher.js</file>
<file>ui/popupMenu.js</file>
<file>ui/quickSettings.js</file>
<file>ui/remoteSearch.js</file>
<file>ui/ripples.js</file>
<file>ui/runDialog.js</file>
<file>ui/screenShield.js</file>
<file>ui/screenshot.js</file>
<file>ui/scripting.js</file>
<file>ui/search.js</file>
<file>ui/searchController.js</file>
<file>ui/sessionMode.js</file>
<file>ui/shellDBus.js</file>
<file>ui/shellEntry.js</file>
<file>ui/shellMountOperation.js</file>
<file>ui/slider.js</file>
<file>ui/swipeTracker.js</file>
<file>ui/switcherPopup.js</file>
<file>ui/switchMonitor.js</file>
<file>ui/unlockDialog.js</file>
<file>ui/userWidget.js</file>
<file>ui/welcomeDialog.js</file>
<file>ui/windowAttentionHandler.js</file>
<file>ui/windowManager.js</file>
<file>ui/windowMenu.js</file>
<file>ui/windowPreview.js</file>
<file>ui/workspace.js</file>
<file>ui/workspaceAnimation.js</file>
<file>ui/workspacesView.js</file>
<file>ui/workspaceSwitcherPopup.js</file>
<file>ui/workspaceThumbnail.js</file>
<file>ui/xdndHandler.js</file>
<file>ui/components.js</file>
<file>ui/components/automountManager.js</file>
<file>ui/components/autorunManager.js</file>
<file>ui/components/keyring.js</file>
<file>ui/components/networkAgent.js</file>
<file>ui/components/polkitAgent.js</file>
<file>ui/status/accessibility.js</file>
<file>ui/status/autoRotate.js</file>
<file>ui/status/backgroundApps.js</file>
<file>ui/status/backlight.js</file>
<file>ui/status/bluetooth.js</file>
<file>ui/status/brightness.js</file>
<file>ui/status/camera.js</file>
<file>ui/status/darkMode.js</file>
<file>ui/status/dwellClick.js</file>
<file>ui/status/keyboard.js</file>
<file>ui/status/location.js</file>
<file>ui/status/network.js</file>
<file>ui/status/nightLight.js</file>
<file>ui/status/powerProfiles.js</file>
<file>ui/status/remoteAccess.js</file>
<file>ui/status/rfkill.js</file>
<file>ui/status/system.js</file>
<file>ui/status/thunderbolt.js</file>
<file>ui/status/volume.js</file>
</gresource>
</gresources>

18
js/meson.build Normal file
View file

@ -0,0 +1,18 @@
subdir('misc')
subdir('dbusServices')
js_resources = gnome.compile_resources(
'js-resources', 'js-resources.gresource.xml',
source_dir: ['.', meson.current_build_dir()],
c_name: 'shell_js_resources',
dependencies: [config_js]
)
if have_portal_helper
portal_resources = gnome.compile_resources(
'portal-resources', 'portal-resources.gresource.xml',
source_dir: ['.', meson.current_build_dir()],
c_name: 'portal_js_resources',
dependencies: [config_js]
)
endif

121
js/misc/animationUtils.js Normal file
View file

@ -0,0 +1,121 @@
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import * as Params from './params.js';
const SCROLL_TIME = 100;
const WIGGLE_OFFSET = 6;
const WIGGLE_DURATION = 65;
const N_WIGGLES = 3;
/**
* adjustAnimationTime:
*
* @param {number} msecs - time in milliseconds
* @param {object} params - optional parameters
* @param {boolean=} params.animationRequired - whether to ignore the enable-animations setting
*
* Adjust `msecs` to account for St's enable-animations
* and slow-down-factor settings
*/
export function adjustAnimationTime(msecs, params) {
params = Params.parse(params, {
animationRequired: false,
});
const settings = St.Settings.get();
if (!settings.enable_animations && !params.animationRequired)
return 0;
return settings.slow_down_factor * msecs;
}
/**
* Animate scrolling a scrollview until an actor is visible.
*
* @param {St.ScrollView} scrollView - the scroll view the actor is in
* @param {Clutter.Actor} actor - the actor
*/
export function ensureActorVisibleInScrollView(scrollView, actor) {
const adjustment = scrollView.vadjustment;
let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();
let offset = 0;
const vfade = scrollView.get_effect('fade');
if (vfade)
offset = vfade.fade_margins.top;
let box = actor.get_allocation_box();
let y1 = box.y1, y2 = box.y2;
let parent = actor.get_parent();
while (parent !== scrollView) {
if (!parent)
throw new Error('actor not in scroll view');
box = parent.get_allocation_box();
y1 += box.y1;
y2 += box.y1;
parent = parent.get_parent();
}
if (y1 < value + offset)
value = Math.max(0, y1 - offset);
else if (y2 > value + pageSize - offset)
value = Math.min(upper, y2 + offset - pageSize);
else
return;
adjustment.ease(value, {
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
duration: SCROLL_TIME,
});
}
/**
* "Wiggles" a clutter actor. A "wiggle" is an animation the moves an actor
* back and forth on the X axis a specified amount of times.
*
* @param {Clutter.Actor} actor - an actor to animate
* @param {object} params - options for the animation
* @param {number} params.offset - the offset to move the actor by per-wiggle
* @param {number} params.duration - the amount of time to move the actor per-wiggle
* @param {number} params.wiggleCount - the number of times to wiggle the actor
*/
export function wiggle(actor, params) {
if (!St.Settings.get().enable_animations)
return;
params = Params.parse(params, {
offset: WIGGLE_OFFSET,
duration: WIGGLE_DURATION,
wiggleCount: N_WIGGLES,
});
actor.translation_x = 0;
// Accelerate before wiggling
actor.ease({
translation_x: -params.offset,
duration: params.duration,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
// Wiggle
actor.ease({
translation_x: params.offset,
duration: params.duration,
mode: Clutter.AnimationMode.LINEAR,
repeatCount: params.wiggleCount,
autoReverse: true,
onComplete: () => {
// Decelerate and return to the original position
actor.ease({
translation_x: 0,
duration: params.duration,
mode: Clutter.AnimationMode.EASE_IN_QUAD,
});
},
});
},
});
}

1476
js/misc/breakManager.js Normal file

File diff suppressed because it is too large Load diff

25
js/misc/config.js.in Normal file
View file

@ -0,0 +1,25 @@
const pkg = imports.package;
/* The name of this package (not localized) */
export const PACKAGE_NAME = '@PACKAGE_NAME@';
/* The version of this package */
export const PACKAGE_VERSION = '@PACKAGE_VERSION@';
/* 1 if networkmanager is available, 0 otherwise */
export const HAVE_NETWORKMANAGER = @HAVE_NETWORKMANAGER@;
/* 1 if portal helper is enabled, 0 otherwise */
export const HAVE_PORTAL_HELPER = @HAVE_PORTAL_HELPER@;
/* gettext package */
export const GETTEXT_PACKAGE = '@GETTEXT_PACKAGE@';
/* locale dir */
export const LOCALEDIR = '@datadir@/locale';
/* other standard directories */
export const LIBEXECDIR = '@libexecdir@';
export const PKGDATADIR = '@datadir@/@PACKAGE_NAME@';
/* g-i package versions */
export const LIBMUTTER_API_VERSION = '@LIBMUTTER_API_VERSION@';
export const HAVE_BLUETOOTH = pkg.checkSymbol('GnomeBluetooth', '3.0',
'Client.default_adapter_state');
export const UTILITIES_FOLDER_APPS = @UTILS_FOLDER_APPS@;
export const SYSTEM_FOLDER_APPS = @SYSTEM_FOLDER_APPS@;

232
js/misc/dateUtils.js Normal file
View file

@ -0,0 +1,232 @@
import * as System from 'system';
import * as Gettext from 'gettext';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Shell from 'gi://Shell';
import * as Params from './params.js';
let _desktopSettings = null;
let _localTimeZone = null;
/**
* @private
*
* @param {Date} date a Date object
* @returns {GLib.DateTime | null}
*/
function _convertJSDateToGLibDateTime(date) {
if (_localTimeZone === null)
_localTimeZone = GLib.TimeZone.new_local();
const dt = GLib.DateTime.new(_localTimeZone,
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds());
return dt;
}
/**
* Formats a Date object according to a C sprintf-style string using
* the cached local timezone.
*
* @param {Date} date a Date object
* @param {string} format a format String for the date
* @returns {string}
*/
export function formatDateWithCFormatString(date, format) {
const dt = _convertJSDateToGLibDateTime(date);
return dt?.format(format) ?? '';
}
/**
* Formats a time span string representing the
* date passed in to the current time.
*
* @param {Date} date the start of the time span
* @returns {string}
*/
export function formatTimeSpan(date) {
if (_localTimeZone === null)
_localTimeZone = GLib.TimeZone.new_local();
const now = GLib.DateTime.new_now(_localTimeZone);
const timespan = now.difference(date);
const minutesAgo = timespan / GLib.TIME_SPAN_MINUTE;
const hoursAgo = timespan / GLib.TIME_SPAN_HOUR;
const daysAgo = timespan / GLib.TIME_SPAN_DAY;
const weeksAgo = daysAgo / 7;
const monthsAgo = daysAgo / 30;
const yearsAgo = weeksAgo / 52;
if (minutesAgo < 5)
return _('Just now');
if (hoursAgo < 1) {
return Gettext.ngettext(
'%d minute ago',
'%d minutes ago',
minutesAgo
).format(minutesAgo);
}
if (daysAgo < 1) {
return Gettext.ngettext(
'%d hour ago',
'%d hours ago',
hoursAgo
).format(hoursAgo);
}
if (daysAgo < 2)
return _('Yesterday');
if (daysAgo < 15) {
return Gettext.ngettext(
'%d day ago',
'%d days ago',
daysAgo
).format(daysAgo);
}
if (weeksAgo < 8) {
return Gettext.ngettext(
'%d week ago',
'%d weeks ago',
weeksAgo
).format(weeksAgo);
}
if (yearsAgo < 1) {
return Gettext.ngettext(
'%d month ago',
'%d months ago',
monthsAgo
).format(monthsAgo);
}
return Gettext.ngettext(
'%d year ago',
'%d years ago',
yearsAgo
).format(yearsAgo);
}
/**
* Formats a date time string based on style parameters
*
* @param {GLib.DateTime | Date} time a Date object
* @param {object} [params] style parameters for the output string
* @param {boolean=} params.timeOnly whether the string should only contain the time (no date)
* @param {boolean=} params.ampm whether to include the "am" or "pm" in the string
* @returns {string}
*/
export function formatTime(time, params) {
let date;
// HACK: The built-in Date type sucks at timezones, which we need for the
// world clock; it's often more convenient though, so allow either
// Date or GLib.DateTime as parameter
if (time instanceof Date)
date = _convertJSDateToGLibDateTime(time);
else
date = time;
if (!date)
return '';
// _localTimeZone is defined in _convertJSDateToGLibDateTime
const now = GLib.DateTime.new_now(_localTimeZone);
const daysAgo = now.difference(date) / (24 * 60 * 60 * 1000 * 1000);
let format;
if (_desktopSettings == null)
_desktopSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'});
const clockFormat = _desktopSettings.get_string('clock-format');
params = Params.parse(params, {
timeOnly: false,
ampm: true,
});
if (clockFormat === '24h') {
// Show only the time if date is on today
if (daysAgo < 1 || params.timeOnly)
/* Translators: Time in 24h format */
format = N_('%H\u2236%M');
// Show the word "Yesterday" and time if date is on yesterday
else if (daysAgo < 2)
/* Translators: this is the word "Yesterday" followed by a
time string in 24h format. i.e. "Yesterday, 14:30" */
// xgettext:no-c-format
format = N_('Yesterday, %H\u2236%M');
// Show a week day and time if date is in the last week
else if (daysAgo < 7)
/* Translators: this is the week day name followed by a time
string in 24h format. i.e. "Monday, 14:30" */
// xgettext:no-c-format
format = N_('%A, %H\u2236%M');
else if (date.get_year() === now.get_year())
/* Translators: this is the month name and day number
followed by a time string in 24h format.
i.e. "May 25, 14:30" */
// xgettext:no-c-format
format = N_('%B %-d, %H\u2236%M');
else
/* Translators: this is the month name, day number, year
number followed by a time string in 24h format.
i.e. "May 25 2012, 14:30" */
// xgettext:no-c-format
format = N_('%B %-d %Y, %H\u2236%M');
} else {
// Show only the time if date is on today
if (daysAgo < 1 || params.timeOnly) // eslint-disable-line no-lonely-if
/* Translators: Time in 12h format */
format = N_('%l\u2236%M %p');
// Show the word "Yesterday" and time if date is on yesterday
else if (daysAgo < 2)
/* Translators: this is the word "Yesterday" followed by a
time string in 12h format. i.e. "Yesterday, 2:30 pm" */
// xgettext:no-c-format
format = N_('Yesterday, %l\u2236%M %p');
// Show a week day and time if date is in the last week
else if (daysAgo < 7)
/* Translators: this is the week day name followed by a time
string in 12h format. i.e. "Monday, 2:30 pm" */
// xgettext:no-c-format
format = N_('%A, %l\u2236%M %p');
else if (date.get_year() === now.get_year())
/* Translators: this is the month name and day number
followed by a time string in 12h format.
i.e. "May 25, 2:30 pm" */
// xgettext:no-c-format
format = N_('%B %-d, %l\u2236%M %p');
else
/* Translators: this is the month name, day number, year
number followed by a time string in 12h format.
i.e. "May 25 2012, 2:30 pm"*/
// xgettext:no-c-format
format = N_('%B %-d %Y, %l\u2236%M %p');
}
// Time in short 12h format, without the equivalent of "AM" or "PM"; used
// when it is clear from the context
if (!params.ampm)
format = format.replace(/\s*%p/g, '');
let formattedTime = date.format(Shell.util_translate_time_string(format));
// prepend LTR-mark to colon/ratio to force a text direction on times
return formattedTime.replace(/([:\u2236])/g, '\u200e$1');
}
/**
* Update the timezone used by JavaScript Date objects and other
* date utilities
*/
export function clearCachedLocalTimeZone() {
// SpiderMonkey caches the time zone so we must explicitly clear it
// before we can update the calendar, see
// https://bugzilla.gnome.org/show_bug.cgi?id=678507
System.clearDateCaches();
_localTimeZone = GLib.TimeZone.new_local();
}

58
js/misc/dbusErrors.js Normal file
View file

@ -0,0 +1,58 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
function camelcase(str) {
const words = str.toLowerCase().split('_');
return words.map(w => `${w.at(0).toUpperCase()}${w.substring(1)}`).join('');
}
function decamelcase(str) {
return str.replace(/(.)([A-Z])/g, '$1-$2');
}
function registerErrorDomain(domain, errorEnum, prefix = 'org.gnome.Shell') {
const domainName =
`shell-${decamelcase(domain).toLowerCase()}-error`;
const quark = GLib.quark_from_string(domainName);
for (const [name, code] of Object.entries(errorEnum)) {
Gio.dbus_error_register_error(quark,
code, `${prefix}.${domain}.Error.${camelcase(name)}`);
}
return quark;
}
export const ModalDialogError = {
UNKNOWN_TYPE: 0,
GRAB_FAILED: 1,
};
export const ModalDialogErrors =
registerErrorDomain('ModalDialog', ModalDialogError);
export const NotificationError = {
INVALID_APP: 0,
};
export const NotificationErrors =
registerErrorDomain('Notifications', NotificationError, 'org.gtk');
export const ExtensionError = {
INFO_DOWNLOAD_FAILED: 0,
DOWNLOAD_FAILED: 1,
EXTRACT_FAILED: 2,
ENABLE_FAILED: 3,
NOT_ALLOWED: 4,
};
export const ExtensionErrors =
registerErrorDomain('Extensions', ExtensionError);
export const ScreencastError = {
ALL_PIPELINES_FAILED: 0,
PIPELINE_ERROR: 1,
SAVE_TO_DISK_DISABLED: 2,
ALREADY_RECORDING: 3,
RECORDER_ERROR: 4,
SERVICE_CRASH: 5,
OUT_OF_DISK_SPACE: 6,
};
export const ScreencastErrors =
registerErrorDomain('Screencast', ScreencastError);

67
js/misc/dbusUtils.js Normal file
View file

@ -0,0 +1,67 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as Config from './config.js';
let _ifaceResource = null;
/**
* @private
*/
function _ensureIfaceResource() {
if (_ifaceResource)
return;
// don't use global.datadir so the method is usable from tests/tools
let dir = GLib.getenv('GNOME_SHELL_DATADIR') || Config.PKGDATADIR;
let path = `${dir}/gnome-shell-dbus-interfaces.gresource`;
_ifaceResource = Gio.Resource.load(path);
_ifaceResource._register();
}
/**
* @param {string} iface the interface name
* @returns {string | null} the XML string or null if it is not found
*/
export function loadInterfaceXML(iface) {
_ensureIfaceResource();
let uri = `resource:///org/gnome/shell/dbus-interfaces/${iface}.xml`;
let f = Gio.File.new_for_uri(uri);
try {
let [ok_, bytes] = f.load_contents(null);
return new TextDecoder().decode(bytes);
} catch {
log(`Failed to load D-Bus interface ${iface}`);
}
return null;
}
/**
* @param {string} iface the interface name
* @param {string} ifaceFile the interface filename
* @returns {string | null} the XML string or null if it is not found
*/
export function loadSubInterfaceXML(iface, ifaceFile) {
let xml = loadInterfaceXML(ifaceFile);
if (!xml)
return null;
let ifaceStartTag = `<interface name="${iface}">`;
let ifaceStopTag = '</interface>';
let ifaceStartIndex = xml.indexOf(ifaceStartTag);
let ifaceEndIndex = xml.indexOf(ifaceStopTag, ifaceStartIndex + 1) + ifaceStopTag.length;
let xmlHeader = '<!DOCTYPE node PUBLIC\n' +
'\'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\'\n' +
'\'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\'>\n' +
'<node>\n';
let xmlFooter = '</node>';
return (
xmlHeader +
xml.substring(ifaceStartIndex, ifaceEndIndex) +
xmlFooter);
}

66
js/misc/dependencies.js Normal file
View file

@ -0,0 +1,66 @@
import gi from 'gi';
/**
* Required dependencies
*/
import 'gi://AccountsService?version=1.0';
import 'gi://Atk?version=1.0';
import 'gi://Atspi?version=2.0';
import 'gi://Gcr?version=4';
import 'gi://Gdk?version=4.0';
import 'gi://Gdm?version=1.0';
import 'gi://Geoclue?version=2.0';
import 'gi://Gio?version=2.0';
import 'gi://GioUnix?version=2.0';
import 'gi://GDesktopEnums?version=3.0';
import 'gi://GdkPixbuf?version=2.0';
import 'gi://GnomeBG?version=4.0';
import 'gi://GnomeDesktop?version=4.0';
import 'gi://Graphene?version=1.0';
import 'gi://GWeather?version=4.0';
import 'gi://IBus?version=1.0';
import 'gi://Pango?version=1.0';
import 'gi://Polkit?version=1.0';
import 'gi://PolkitAgent?version=1.0';
import 'gi://Rsvg?version=2.0';
import 'gi://Soup?version=3.0';
import 'gi://UPowerGlib?version=1.0';
import * as Config from './config.js';
// Meta-related dependencies use a shared version
// from the compile-time config.
gi.require('Meta', Config.LIBMUTTER_API_VERSION);
gi.require('Clutter', Config.LIBMUTTER_API_VERSION);
gi.require('Cogl', Config.LIBMUTTER_API_VERSION);
gi.require('Shell', Config.LIBMUTTER_API_VERSION);
gi.require('St', Config.LIBMUTTER_API_VERSION);
/**
* Compile-time optional dependencies
*/
if (Config.HAVE_BLUETOOTH)
gi.require('GnomeBluetooth', '3.0');
else
console.debug('GNOME Shell was compiled without GNOME Bluetooth support');
if (Config.HAVE_NETWORKMANAGER) {
gi.require('NM', '1.0');
gi.require('NMA4', '1.0');
} else {
console.debug('GNOME Shell was compiled without Network Manager support');
}
/**
* Runtime optional dependencies
*/
try {
// Malcontent is optional, so catch any errors loading it
gi.require('Malcontent', '0');
} catch {
console.debug('Malcontent is not available, parental controls integration will be disabled.');
}

66
js/misc/errorUtils.js Normal file
View file

@ -0,0 +1,66 @@
// Common code for displaying errors to the user in various dialogs
function formatSyntaxErrorLocation(error) {
const {fileName = '<unknown>', lineNumber = 0, columnNumber = 0} = error;
return ` @ ${fileName}:${lineNumber}:${columnNumber}`;
}
function formatExceptionStack(error) {
const {stack} = error;
if (!stack)
return '\n\n(No stack trace)';
const indentedStack = stack.split('\n').map(line => ` ${line}`).join('\n');
return `\n\nStack trace:\n${indentedStack}`;
}
function formatExceptionWithCause(error, seenCauses, showStack) {
let fmt = showStack ? formatExceptionStack(error) : '';
const {cause} = error;
if (!cause)
return fmt;
fmt += `\nCaused by: ${cause}`;
if (cause !== null && typeof cause === 'object') {
if (seenCauses.has(cause))
return fmt; // avoid recursion
seenCauses.add(cause);
fmt += formatExceptionWithCause(cause, seenCauses);
}
return fmt;
}
/**
* Formats a thrown exception into a string, including the stack, taking the
* location where a SyntaxError was thrown into account.
*
* @param {Error} error The error to format
* @param {object} options Formatting options
* @param {boolean} options.showStack Whether to show the stack trace (default
* true)
* @returns {string} The formatted string
*/
export function formatError(error, {showStack = true} = {}) {
try {
let fmt = `${error}`;
if (error === null || typeof error !== 'object')
return fmt;
if (error instanceof SyntaxError) {
fmt += formatSyntaxErrorLocation(error);
if (showStack)
fmt += formatExceptionStack(error);
return fmt;
}
const seenCauses = new Set([error]);
fmt += formatExceptionWithCause(error, seenCauses, showStack);
return fmt;
} catch (e) {
return `(could not display error: ${e})`;
}
}

172
js/misc/extensionUtils.js Normal file
View file

@ -0,0 +1,172 @@
// Common utils for the extension system, the extensions D-Bus service
// and the Extensions app
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
export const ExtensionType = {
SYSTEM: 1,
PER_USER: 2,
};
/**
* @enum {number}
*/
export const ExtensionState = {
ACTIVE: 1,
INACTIVE: 2,
ERROR: 3,
OUT_OF_DATE: 4,
DOWNLOADING: 5,
INITIALIZED: 6,
DEACTIVATING: 7,
ACTIVATING: 8,
// Used as an error state for operations on unknown extensions,
// should never be in a real extensionMeta object.
UNINSTALLED: 99,
};
const SERIALIZED_PROPERTIES = [
'type',
'state',
'enabled',
'path',
'error',
'hasPrefs',
'hasUpdate',
'canChange',
'sessionModes',
];
/**
* Serialize extension into an object that can be used
* in a vardict {GLib.Variant}
*
* @param {object} extension - an extension object
* @returns {object}
*/
export function serializeExtension(extension) {
let obj = {...extension.metadata};
SERIALIZED_PROPERTIES.forEach(prop => {
obj[prop] = extension[prop];
});
function packValue(val) {
let type;
switch (typeof val) {
case 'string':
type = 's';
break;
case 'number':
type = 'd';
break;
case 'boolean':
type = 'b';
break;
case 'object':
if (Array.isArray(val)) {
type = 'av';
val = val.map(v => packValue(v));
} else {
type = 'a{sv}';
let res = {};
for (let key in val) {
let packed = packValue(val[key]);
if (packed)
res[key] = packed;
}
val = res;
}
break;
default:
return null;
}
return GLib.Variant.new(type, val);
}
return packValue(obj).deepUnpack();
}
/**
* Deserialize an unpacked variant into an extension object
*
* @param {object} variant - an unpacked {GLib.Variant}
* @returns {object}
*/
export function deserializeExtension(variant) {
let res = {metadata: {}};
for (let prop in variant) {
let val = variant[prop].recursiveUnpack();
if (SERIALIZED_PROPERTIES.includes(prop))
res[prop] = val;
else
res.metadata[prop] = val;
}
// add the 2 additional properties to create a valid extension object, as createExtensionObject()
res.uuid = res.metadata.uuid;
res.dir = Gio.File.new_for_path(res.path);
return res;
}
/**
* Load extension metadata from directory
*
* @param {string} uuid of the extension
* @param {GioFile} dir to load metadata from
* @returns {object}
*/
export function loadExtensionMetadata(uuid, dir) {
const dirName = dir.get_basename();
if (dirName !== uuid)
throw new Error(`Directory name "${dirName}" does not match UUID "${uuid}"`);
const metadataFile = dir.get_child('metadata.json');
if (!metadataFile.query_exists(null))
throw new Error('Missing metadata.json');
let metadataContents, success_;
try {
[success_, metadataContents] = metadataFile.load_contents(null);
metadataContents = new TextDecoder().decode(metadataContents);
} catch (e) {
throw new Error(`Failed to load metadata.json: ${e}`);
}
let meta;
try {
meta = JSON.parse(metadataContents);
} catch (e) {
throw new Error(`Failed to parse metadata.json: ${e}`);
}
const requiredProperties = [{
prop: 'uuid',
typeName: 'string',
}, {
prop: 'name',
typeName: 'string',
}, {
prop: 'description',
typeName: 'string',
}, {
prop: 'shell-version',
typeName: 'string array',
typeCheck: v => Array.isArray(v) && v.length > 0 && v.every(e => typeof e === 'string'),
}];
for (let i = 0; i < requiredProperties.length; i++) {
const {
prop, typeName, typeCheck = v => typeof v === typeName,
} = requiredProperties[i];
if (!meta[prop])
throw new Error(`missing "${prop}" property in metadata.json`);
if (!typeCheck(meta[prop]))
throw new Error(`property "${prop}" is not of type ${typeName}`);
}
if (uuid !== meta.uuid)
throw new Error(`UUID "${meta.uuid}" from metadata.json does not match directory name "${uuid}"`);
return meta;
}

86
js/misc/fileUtils.js Normal file
View file

@ -0,0 +1,86 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
export {loadInterfaceXML} from './dbusUtils.js';
/**
* @typedef {object} SubdirInfo
* @property {Gio.File} dir the file object for the subdir
* @property {Gio.FileInfo} info the file descriptor for the subdir
*/
/**
* @param {string} subdir the subdirectory to search within the data directories
* @param {boolean} includeUserDir whether the user's data directory should also be searched in addition
* to the system data directories
* @returns {Generator<SubdirInfo, void, void>} a generator which yields file info for subdirectories named
* `subdir` within data directories
*/
export function* collectFromDatadirs(subdir, includeUserDir) {
let dataDirs = GLib.get_system_data_dirs();
if (includeUserDir)
dataDirs.unshift(GLib.get_user_data_dir());
for (let i = 0; i < dataDirs.length; i++) {
let path = GLib.build_filenamev([dataDirs[i], 'gnome-shell', subdir]);
let dir = Gio.File.new_for_path(path);
let fileEnum;
try {
fileEnum = dir.enumerate_children('standard::name,standard::type',
Gio.FileQueryInfoFlags.NONE, null);
} catch {
fileEnum = null;
}
if (fileEnum != null) {
let info;
while ((info = fileEnum.next_file(null)))
yield {dir: fileEnum.get_child(info), info};
}
}
}
/**
* @param {Gio.File} dir
* @param {boolean} deleteParent
*/
export function recursivelyDeleteDir(dir, deleteParent) {
let children = dir.enumerate_children('standard::name,standard::type',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
let info;
while ((info = children.next_file(null)) != null) {
let type = info.get_file_type();
let child = dir.get_child(info.get_name());
if (type === Gio.FileType.REGULAR || type === Gio.FileType.SYMBOLIC_LINK)
child.delete(null);
else if (type === Gio.FileType.DIRECTORY)
recursivelyDeleteDir(child, true);
}
if (deleteParent)
dir.delete(null);
}
/**
* @param {Gio.File} srcDir
* @param {Gio.File} destDir
*/
export function recursivelyMoveDir(srcDir, destDir) {
let children = srcDir.enumerate_children('standard::name,standard::type',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
if (!destDir.query_exists(null))
destDir.make_directory_with_parents(null);
let info;
while ((info = children.next_file(null)) != null) {
let type = info.get_file_type();
let srcChild = srcDir.get_child(info.get_name());
let destChild = destDir.get_child(info.get_name());
if (type === Gio.FileType.REGULAR || type === Gio.FileType.SYMBOLIC_LINK)
srcChild.move(destChild, Gio.FileCopyFlags.NONE, null, null);
else if (type === Gio.FileType.DIRECTORY)
recursivelyMoveDir(srcChild, destChild);
}
}

64
js/misc/gnomeSession.js Normal file
View file

@ -0,0 +1,64 @@
import Gio from 'gi://Gio';
import {loadInterfaceXML} from './fileUtils.js';
const PresenceIface = loadInterfaceXML('org.gnome.SessionManager.Presence');
/** @enum {number} */
export const PresenceStatus = {
AVAILABLE: 0,
INVISIBLE: 1,
BUSY: 2,
IDLE: 3,
};
const PresenceProxy = Gio.DBusProxy.makeProxyWrapper(PresenceIface);
/**
* @param {Function} initCallback
* @param {Gio.Cancellable} cancellable
* @returns {Gio.DBusProxy}
*/
export function Presence(initCallback, cancellable) {
return new PresenceProxy(Gio.DBus.session,
'org.gnome.SessionManager',
'/org/gnome/SessionManager/Presence',
initCallback, cancellable);
}
// Note inhibitors are immutable objects, so they don't
// change at runtime (changes always come in the form
// of new inhibitors)
const InhibitorIface = loadInterfaceXML('org.gnome.SessionManager.Inhibitor');
const InhibitorProxy = Gio.DBusProxy.makeProxyWrapper(InhibitorIface);
/**
* @param {string} objectPath
* @param {Function} initCallback
* @param {Gio.Cancellable} cancellable
* @returns {Gio.DBusProxy}
*/
export function Inhibitor(objectPath, initCallback, cancellable) {
return new InhibitorProxy(Gio.DBus.session, 'org.gnome.SessionManager', objectPath, initCallback, cancellable);
}
// Not the full interface, only the methods we use
const SessionManagerIface = loadInterfaceXML('org.gnome.SessionManager');
const SessionManagerProxy = Gio.DBusProxy.makeProxyWrapper(SessionManagerIface);
/**
* @param {Function} initCallback
* @param {Gio.Cancellable} cancellable
* @returns {Gio.DBusProxy}
*/
export function SessionManager(initCallback, cancellable) {
return new SessionManagerProxy(Gio.DBus.session, 'org.gnome.SessionManager', '/org/gnome/SessionManager', initCallback, cancellable);
}
export const InhibitFlags = {
LOGOUT: 1 << 0,
SWITCH: 1 << 1,
SUSPEND: 1 << 2,
IDLE: 1 << 3,
AUTOMOUNT: 1 << 4,
};

111
js/misc/history.js Normal file
View file

@ -0,0 +1,111 @@
import * as Signals from './signals.js';
import Clutter from 'gi://Clutter';
import * as Params from './params.js';
const DEFAULT_LIMIT = 512;
export class HistoryManager extends Signals.EventEmitter {
constructor(params) {
super();
params = Params.parse(params, {
gsettingsKey: null,
limit: DEFAULT_LIMIT,
entry: null,
});
this._key = params.gsettingsKey;
this._limit = params.limit;
this._historyIndex = 0;
if (this._key) {
this._history = global.settings.get_strv(this._key);
global.settings.connect(`changed::${this._key}`,
this._historyChanged.bind(this));
} else {
this._history = [];
}
this._entry = params.entry;
if (this._entry) {
this._entry.connect('key-press-event',
this._onEntryKeyPress.bind(this));
}
}
_historyChanged() {
this._history = global.settings.get_strv(this._key);
this._historyIndex = this._history.length;
}
_setPrevItem(text) {
if (this._historyIndex <= 0)
return false;
if (text)
this._history[this._historyIndex] = text;
this._historyIndex--;
this._indexChanged();
return true;
}
_setNextItem(text) {
if (this._historyIndex >= this._history.length)
return false;
if (text)
this._history[this._historyIndex] = text;
this._historyIndex++;
this._indexChanged();
return true;
}
lastItem() {
if (this._historyIndex !== this._history.length) {
this._historyIndex = this._history.length;
this._indexChanged();
}
return this._historyIndex ? this._history[this._historyIndex - 1] : null;
}
addItem(input) {
input = input.trim();
if (input &&
(this._history.length === 0 ||
this._history[this._history.length - 1] !== input)) {
this._history = this._history.filter(entry => entry !== input);
this._history.push(input);
this._save();
}
this._historyIndex = this._history.length;
return input; // trimmed
}
_onEntryKeyPress(entry, event) {
let symbol = event.get_key_symbol();
if (symbol === Clutter.KEY_Up)
return this._setPrevItem(entry.get_text().trim());
else if (symbol === Clutter.KEY_Down)
return this._setNextItem(entry.get_text().trim());
return Clutter.EVENT_PROPAGATE;
}
_indexChanged() {
let current = this._history[this._historyIndex] || '';
this.emit('changed', current);
if (this._entry)
this._entry.set_text(current);
}
_save() {
if (this._history.length > this._limit)
this._history.splice(0, this._history.length - this._limit);
if (this._key)
global.settings.set_strv(this._key, this._history);
}
}

368
js/misc/ibusManager.js Normal file
View file

@ -0,0 +1,368 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import IBus from 'gi://IBus';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import * as Signals from './signals.js';
import * as BoxPointer from '../ui/boxpointer.js';
import * as IBusCandidatePopup from '../ui/ibusCandidatePopup.js';
Gio._promisify(IBus.Bus.prototype,
'list_engines_async', 'list_engines_async_finish');
Gio._promisify(IBus.Bus.prototype,
'request_name_async', 'request_name_async_finish');
Gio._promisify(IBus.Bus.prototype,
'get_global_engine_async', 'get_global_engine_async_finish');
Gio._promisify(IBus.Bus.prototype,
'set_global_engine_async', 'set_global_engine_async_finish');
Gio._promisify(Shell, 'util_systemd_unit_exists');
// Ensure runtime version matches
_checkIBusVersion(1, 5, 2);
let _ibusManager = null;
const IBUS_SYSTEMD_SERVICE = 'org.freedesktop.IBus.session.GNOME.service';
const TYPING_BOOSTER_ENGINE = 'typing-booster';
function _checkIBusVersion(requiredMajor, requiredMinor, requiredMicro) {
if ((IBus.MAJOR_VERSION > requiredMajor) ||
(IBus.MAJOR_VERSION === requiredMajor && IBus.MINOR_VERSION > requiredMinor) ||
(IBus.MAJOR_VERSION === requiredMajor && IBus.MINOR_VERSION === requiredMinor &&
IBus.MICRO_VERSION >= requiredMicro))
return;
throw new Error(`Found IBus version ${
IBus.MAJOR_VERSION}.${IBus.MINOR_VERSION}.${IBus.MINOR_VERSION} ` +
`but required is ${requiredMajor}.${requiredMinor}.${requiredMicro}`);
}
/**
* @returns {IBusManager}
*/
export function getIBusManager() {
if (_ibusManager == null)
_ibusManager = new IBusManager();
return _ibusManager;
}
class IBusManager extends Signals.EventEmitter {
constructor() {
super();
IBus.init();
// This is the longest we'll keep the keyboard frozen until an input
// source is active.
this._MAX_INPUT_SOURCE_ACTIVATION_TIME = 4000; // ms
this._PRELOAD_ENGINES_DELAY_TIME = 30; // sec
this._candidatePopup = new IBusCandidatePopup.CandidatePopup();
this._panelService = null;
this._engines = new Map();
this._ready = false;
this._registerPropertiesId = 0;
this._currentEngineName = null;
this._preloadEnginesId = 0;
this._ibus = IBus.Bus.new_async();
this._ibus.connect('connected', this._onConnected.bind(this));
this._ibus.connect('disconnected', this._clear.bind(this));
// Need to set this to get 'global-engine-changed' emitions
this._ibus.set_watch_ibus_signal(true);
this._ibus.connect('global-engine-changed', this._engineChanged.bind(this));
this._queueSpawn();
}
async _ibusSystemdServiceExists() {
if (this._ibusIsSystemdService)
return true;
try {
this._ibusIsSystemdService =
await Shell.util_systemd_unit_exists(
IBUS_SYSTEMD_SERVICE, null);
} catch {
this._ibusIsSystemdService = false;
}
return this._ibusIsSystemdService;
}
async _queueSpawn() {
const isSystemdService = await this._ibusSystemdServiceExists();
if (!isSystemdService)
this._spawn(Meta.is_wayland_compositor() ? [] : ['--xim']);
}
_tryAppendEnv(env, varname) {
const value = GLib.getenv(varname);
if (value)
env.push(`${varname}=${value}`);
}
_spawn(extraArgs = []) {
try {
const cmdLine = ['ibus-daemon', '--panel', 'disable', ...extraArgs];
const launchContext = global.create_app_launch_context(0, -1);
const env = launchContext.get_environment();
// Use DO_NOT_REAP_CHILD to avoid adouble-fork internally
// since ibus-daemon refuses to start with init as its parent.
const pid = Shell.util_spawn_async(
null, cmdLine, env,
GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD);
GLib.child_watch_add(
GLib.PRIORITY_DEFAULT,
pid,
() => GLib.spawn_close_pid(pid)
);
} catch (e) {
log(`Failed to launch ibus-daemon: ${e.message}`);
}
}
async restartDaemon(extraArgs = []) {
const isSystemdService = await this._ibusSystemdServiceExists();
if (!isSystemdService)
this._spawn(['-r', ...extraArgs]);
}
_clear() {
if (this._cancellable) {
this._cancellable.cancel();
this._cancellable = null;
}
if (this._preloadEnginesId) {
GLib.source_remove(this._preloadEnginesId);
this._preloadEnginesId = 0;
}
if (this._panelService)
this._panelService.destroy();
this._panelService = null;
this._candidatePopup.setPanelService(null);
this._engines.clear();
this._ready = false;
this._registerPropertiesId = 0;
this._currentEngineName = null;
this.emit('ready', false);
}
_onConnected() {
this._cancellable = new Gio.Cancellable();
this._initEngines();
this._initPanelService();
}
async _initEngines() {
try {
const enginesList =
await this._ibus.list_engines_async(-1, this._cancellable);
for (let i = 0; i < enginesList.length; ++i) {
let name = enginesList[i].get_name();
this._engines.set(name, enginesList[i]);
}
this._updateReadiness();
} catch (e) {
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
return;
logError(e);
this._clear();
}
}
async _initPanelService() {
try {
await this._ibus.request_name_async(IBus.SERVICE_PANEL,
IBus.BusNameFlag.REPLACE_EXISTING, -1, this._cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
logError(e);
this._clear();
}
return;
}
this._panelService = new IBus.PanelService({
connection: this._ibus.get_connection(),
object_path: IBus.PATH_PANEL,
});
this._candidatePopup.setPanelService(this._panelService);
this._panelService.connect('update-property', this._updateProperty.bind(this));
this._panelService.connect('set-cursor-location', (ps, x, y, w, h) => {
let cursorLocation = {x, y, width: w, height: h};
this.emit('set-cursor-location', cursorLocation);
});
this._panelService.connect('focus-in', (panel, path) => {
if (!GLib.str_has_suffix(path, '/InputContext_1'))
this.emit('focus-in');
});
this._panelService.connect('focus-out', () => this.emit('focus-out'));
try {
// IBus versions older than 1.5.10 have a bug which
// causes spurious set-content-type emissions when
// switching input focus that temporarily lose purpose
// and hints defeating its intended semantics and
// confusing users. We thus don't use it in that case.
_checkIBusVersion(1, 5, 10);
this._panelService.connect('set-content-type', this._setContentType.bind(this));
} catch {
}
this._updateReadiness();
try {
// If an engine is already active we need to get its properties
const engine =
await this._ibus.get_global_engine_async(-1, this._cancellable);
this._engineChanged(this._ibus, engine.get_name());
} catch {
}
}
_updateReadiness() {
this._ready = this._engines.size > 0 && this._panelService != null;
this.emit('ready', this._ready);
}
_engineChanged(bus, engineName) {
if (!this._ready)
return;
this._currentEngineName = engineName;
this._candidatePopup.close(BoxPointer.PopupAnimation.NONE);
if (this._registerPropertiesId !== 0)
return;
this._registerPropertiesId =
this._panelService.connect('register-properties', (p, props) => {
if (!props.get(0))
return;
this._panelService.disconnect(this._registerPropertiesId);
this._registerPropertiesId = 0;
this.emit('properties-registered', this._currentEngineName, props);
});
}
_updateProperty(panel, prop) {
this.emit('property-updated', this._currentEngineName, prop);
}
_setContentType(panel, purpose, hints) {
this.emit('set-content-type', purpose, hints);
}
activateProperty(key, state) {
this._panelService.property_activate(key, state);
}
getEngineDesc(id) {
if (!this._ready || !this._engines.has(id))
return null;
return this._engines.get(id);
}
async _setEngine(id) {
// Send id even if id == this._currentEngineName
// because 'properties-registered' signal can be emitted
// while this._ibusSources == null on a lock screen.
if (!this._ready)
return;
try {
await this._ibus.set_global_engine_async(id,
this._MAX_INPUT_SOURCE_ACTIVATION_TIME,
this._cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
async setEngine(id) {
if (this._oskCompletion)
await this._maybeUpdateCompletion(id);
else
await this._setEngine(id);
}
async _maybeUpdateCompletion(id) {
if (!this._oskCompletion)
return;
this._preOskEngine = id;
const isXkb = id.startsWith('xkb:');
/* Non xkb engines conflict with completion */
if (!isXkb)
await this.setCompletionEnabled(false);
}
preloadEngines(ids) {
if (!this._ibus || !this._ready)
return;
if (!ids.includes(TYPING_BOOSTER_ENGINE))
ids.push(TYPING_BOOSTER_ENGINE);
if (this._preloadEnginesId !== 0) {
GLib.source_remove(this._preloadEnginesId);
this._preloadEnginesId = 0;
}
this._preloadEnginesId =
GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
this._PRELOAD_ENGINES_DELAY_TIME,
() => {
this._ibus.preload_engines_async(
ids,
-1,
this._cancellable,
null);
this._preloadEnginesId = 0;
return GLib.SOURCE_REMOVE;
});
}
/**
* @param {boolean} enabled - whether completion should be enabled
*
* @returns {boolean} - whether completion are enabled
*/
async setCompletionEnabled(enabled) {
/* Needs typing-booster available */
if (enabled && !this._engines.has(TYPING_BOOSTER_ENGINE))
return false;
/* Can do only on xkb engines */
if (enabled && !this._currentEngineName.startsWith('xkb:'))
return false;
if (this._oskCompletion === enabled)
return enabled;
this._oskCompletion = enabled;
if (enabled) {
this._preOskEngine = this._currentEngineName;
await this._setEngine(TYPING_BOOSTER_ENGINE);
} else if (this._preOskEngine) {
await this._setEngine(this._preOskEngine);
delete this._preOskEngine;
}
return this._oskCompletion;
}
}

407
js/misc/inputMethod.js Normal file
View file

@ -0,0 +1,407 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import IBus from 'gi://IBus';
import * as Keyboard from '../ui/status/keyboard.js';
import * as Main from '../ui/main.js';
Gio._promisify(IBus.Bus.prototype,
'create_input_context_async', 'create_input_context_async_finish');
Gio._promisify(IBus.InputContext.prototype,
'process_key_event_async', 'process_key_event_async_finish');
const HIDE_PANEL_TIME = 50;
const HAVE_REQUIRE_SURROUNDING_TEXT = GObject.signal_lookup('require-surrounding-text', IBus.InputContext);
export const InputMethod = GObject.registerClass({
Signals: {
'surrounding-text-set': {},
},
}, class InputMethod extends Clutter.InputMethod {
_init() {
super._init();
this._hints = 0;
this._purpose = 0;
this._currentFocus = null;
this._preeditStr = '';
this._preeditPos = 0;
this._preeditAnchor = 0;
this._preeditVisible = false;
this._hidePanelId = 0;
this._surroundingText = null;
this._surroundingTextCursor = null;
this._surroundingTextAnchor = null;
this._ibus = IBus.Bus.new_async();
this._ibus.connect('connected', this._onConnected.bind(this));
this._ibus.connect('disconnected', this._clear.bind(this));
this.connect('notify::can-show-preedit', this._updateCapabilities.bind(this));
this._inputSourceManager = Keyboard.getInputSourceManager();
this._sourceChangedId = this._inputSourceManager.connect('current-source-changed',
this._onSourceChanged.bind(this));
this._currentSource = this._inputSourceManager.currentSource;
if (this._ibus.is_connected())
this._onConnected();
}
get currentFocus() {
return this._currentFocus;
}
_updateCapabilities() {
let caps = IBus.Capabilite.PREEDIT_TEXT | IBus.Capabilite.FOCUS;
if (this._surroundingText !== null)
caps |= IBus.Capabilite.SURROUNDING_TEXT;
if (Main.keyboard.visible)
caps |= IBus.Capabilite.OSK;
if (this._context)
this._context.set_capabilities(caps);
}
_onSourceChanged() {
this._currentSource = this._inputSourceManager.currentSource;
}
async _onConnected() {
this._cancellable = new Gio.Cancellable();
try {
this._context = await this._ibus.create_input_context_async(
'gnome-shell', -1, this._cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
logError(e);
this._clear();
}
return;
}
this._context.set_client_commit_preedit(true);
this._context.connect('commit-text', this._onCommitText.bind(this));
this._context.connect('delete-surrounding-text', this._onDeleteSurroundingText.bind(this));
this._context.connect('update-preedit-text-with-mode', this._onUpdatePreeditText.bind(this));
this._context.connect('show-preedit-text', this._onShowPreeditText.bind(this));
this._context.connect('hide-preedit-text', this._onHidePreeditText.bind(this));
this._context.connect('forward-key-event', this._onForwardKeyEvent.bind(this));
this._context.connect('destroy', this._clear.bind(this));
if (HAVE_REQUIRE_SURROUNDING_TEXT)
this._context.connect('require-surrounding-text', this._onRequireSurroundingText.bind(this));
Main.keyboard.connectObject('visibility-changed', () => this._updateCapabilities());
this._updateCapabilities();
}
_clear() {
Main.keyboard.disconnectObject(this);
if (this._cancellable) {
this._cancellable.cancel();
this._cancellable = null;
}
this._context = null;
this._hints = 0;
this._purpose = 0;
this._preeditStr = '';
this._preeditPos = 0;
this._preeditAnchor = 0;
this._preeditVisible = false;
}
_emitRequestSurrounding() {
if (this._context.needs_surrounding_text())
this.emit('request-surrounding');
}
_onCommitText(_context, text) {
this.commit(text.get_text());
}
_onRequireSurroundingText(_context) {
this.request_surrounding();
}
_onDeleteSurroundingText(_context, offset, nchars) {
if (this._surroundingText === null) {
log('input-method engines should not call ' +
'the delete-surrounding-text API in case ' +
'the input context has no SURROUNDING_TEXT capability.');
return;
}
try {
this.delete_surrounding(offset, nchars);
} catch {
// We may get out of bounds for negative offset on older mutter
this.delete_surrounding(0, nchars + offset);
}
}
_onUpdatePreeditText(_context, text, pos, visible, mode) {
if (text == null)
return;
let preedit = text.get_text();
if (preedit === '')
preedit = null;
const anchor = pos;
if (visible)
this.set_preedit_text(preedit, pos, anchor, mode);
else if (this._preeditVisible)
this.set_preedit_text(null, pos, anchor, mode);
this._preeditStr = preedit;
this._preeditPos = pos;
this._preeditAnchor = anchor;
this._preeditVisible = visible;
this._preeditCommitMode = mode;
}
_onShowPreeditText() {
this._preeditVisible = true;
this.set_preedit_text(
this._preeditStr, this._preeditPos, this._preeditAnchor,
this._preeditCommitMode);
}
_onHidePreeditText() {
this.set_preedit_text(
null, this._preeditPos, this._preeditAnchor,
this._preeditCommitMode);
this._preeditVisible = false;
}
_onForwardKeyEvent(_context, keyval, keycode, state) {
let press = (state & IBus.ModifierType.RELEASE_MASK) === 0;
state &= ~IBus.ModifierType.RELEASE_MASK;
let curEvent = Clutter.get_current_event();
let time;
if (curEvent)
time = curEvent.get_time();
else
time = global.display.get_current_time_roundtrip();
this.forward_key(keyval, keycode + 8, state & Clutter.ModifierType.MODIFIER_MASK, time, press);
}
vfunc_focus_in(focus) {
this._currentFocus = focus;
if (this._context) {
this.update();
this._context.focus_in();
this._emitRequestSurrounding();
}
if (this._hidePanelId) {
GLib.source_remove(this._hidePanelId);
this._hidePanelId = 0;
}
}
vfunc_focus_out() {
this._currentFocus = null;
if (this._context) {
this._fullReset();
this._context.focus_out();
}
if (this._preeditStr && this._preeditVisible) {
// Unset any preedit text
this.set_preedit_text(null, 0, 0, this._preeditCommitMode);
this._preeditStr = null;
}
this._hidePanelId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, HIDE_PANEL_TIME, () => {
this.set_input_panel_state(Clutter.InputPanelState.OFF);
this._hidePanelId = 0;
return GLib.SOURCE_REMOVE;
});
}
vfunc_reset() {
if (this._context) {
this._context.reset();
this._emitRequestSurrounding();
}
this._surroundingText = null;
this._surroundingTextCursor = null;
this._surroundingTextAnchor = null;
this._preeditStr = null;
}
vfunc_set_cursor_location(rect) {
if (this._context) {
this._cursorRect = {
x: rect.get_x(), y: rect.get_y(),
width: rect.get_width(), height: rect.get_height(),
};
this._context.set_cursor_location(
this._cursorRect.x, this._cursorRect.y,
this._cursorRect.width, this._cursorRect.height);
this._emitRequestSurrounding();
}
}
vfunc_set_surrounding(text, cursor, anchor) {
// If the previous input context supports the surrounding-text feature.
const prevHasSurrounding = this._surroundingText !== null;
// If the current input context supports the surrounding-text feature.
const nowHasSurrounding = text !== null;
// If the SURROUNDING_TEXT capability is changed.
const updateCapabilities = prevHasSurrounding !== nowHasSurrounding;
this._surroundingText = text;
this._surroundingTextCursor = cursor;
this._surroundingTextAnchor = anchor;
this.emit('surrounding-text-set');
if (!this._context || (!text && text !== ''))
return;
let ibusText = IBus.Text.new_from_string(text);
if (updateCapabilities)
this._updateCapabilities();
// Call context.set_surrounding_text() after context.set_capabilities().
this._context.set_surrounding_text(ibusText, cursor, anchor);
}
vfunc_update_content_hints(hints) {
let ibusHints = 0;
if (hints & Clutter.InputContentHintFlags.COMPLETION)
ibusHints |= IBus.InputHints.WORD_COMPLETION;
if (hints & Clutter.InputContentHintFlags.SPELLCHECK)
ibusHints |= IBus.InputHints.SPELLCHECK;
if (hints & Clutter.InputContentHintFlags.AUTO_CAPITALIZATION)
ibusHints |= IBus.InputHints.UPPERCASE_SENTENCES;
if (hints & Clutter.InputContentHintFlags.LOWERCASE)
ibusHints |= IBus.InputHints.LOWERCASE;
if (hints & Clutter.InputContentHintFlags.UPPERCASE)
ibusHints |= IBus.InputHints.UPPERCASE_CHARS;
if (hints & Clutter.InputContentHintFlags.TITLECASE)
ibusHints |= IBus.InputHints.UPPERCASE_WORDS;
if (hints & Clutter.InputContentHintFlags.SENSITIVE_DATA)
ibusHints |= IBus.InputHints.PRIVATE;
this._hints = ibusHints;
if (this._context)
this._context.set_content_type(this._purpose, this._hints);
}
vfunc_update_content_purpose(purpose) {
let ibusPurpose = 0;
if (purpose === Clutter.InputContentPurpose.NORMAL)
ibusPurpose = IBus.InputPurpose.FREE_FORM;
else if (purpose === Clutter.InputContentPurpose.ALPHA)
ibusPurpose = IBus.InputPurpose.ALPHA;
else if (purpose === Clutter.InputContentPurpose.DIGITS)
ibusPurpose = IBus.InputPurpose.DIGITS;
else if (purpose === Clutter.InputContentPurpose.NUMBER)
ibusPurpose = IBus.InputPurpose.NUMBER;
else if (purpose === Clutter.InputContentPurpose.PHONE)
ibusPurpose = IBus.InputPurpose.PHONE;
else if (purpose === Clutter.InputContentPurpose.URL)
ibusPurpose = IBus.InputPurpose.URL;
else if (purpose === Clutter.InputContentPurpose.EMAIL)
ibusPurpose = IBus.InputPurpose.EMAIL;
else if (purpose === Clutter.InputContentPurpose.NAME)
ibusPurpose = IBus.InputPurpose.NAME;
else if (purpose === Clutter.InputContentPurpose.PASSWORD)
ibusPurpose = IBus.InputPurpose.PASSWORD;
else if (purpose === Clutter.InputContentPurpose.TERMINAL &&
IBus.InputPurpose.TERMINAL)
ibusPurpose = IBus.InputPurpose.TERMINAL;
this._purpose = ibusPurpose;
if (this._context)
this._context.set_content_type(this._purpose, this._hints);
}
vfunc_filter_key_event(event) {
if (!this._context)
return false;
if (!this._currentSource)
return false;
let state = event.get_state();
if (state & IBus.ModifierType.IGNORED_MASK)
return false;
if (event.type() === Clutter.EventType.KEY_RELEASE)
state |= IBus.ModifierType.RELEASE_MASK;
this._context.process_key_event_async(
event.get_key_symbol(),
event.get_key_code() - 8, // Convert XKB keycodes to evcodes
state, -1, this._cancellable,
(context, res) => {
if (context !== this._context)
return;
try {
let retval = context.process_key_event_async_finish(res);
this.notify_key_event(event, retval);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
log(`Error processing key on IM: ${e.message}`);
}
});
return true;
}
getSurroundingText() {
return [
this._surroundingText,
this._surroundingTextCursor,
this._surroundingTextAnchor,
];
}
hasPreedit() {
return this._preeditVisible && this._preeditStr !== '' && this._preeditStr !== null;
}
async handleVirtualKey(keyval) {
try {
if (!await this._context.process_key_event_async(
keyval, 0, 0, -1, null))
return false;
await this._context.process_key_event_async(
keyval, 0, IBus.ModifierType.RELEASE_MASK, -1, null);
return true;
} catch {
return false;
}
}
_fullReset() {
this._context.set_content_type(0, 0);
this._context.set_cursor_location(0, 0, 0, 0);
this._context.reset();
}
update() {
if (!this._context)
return;
this._updateCapabilities();
this._context.set_content_type(this._purpose, this._hints);
if (this._cursorRect) {
this._context.set_cursor_location(
this._cursorRect.x, this._cursorRect.y,
this._cursorRect.width, this._cursorRect.height);
}
this._emitRequestSurrounding();
}
});

218
js/misc/introspect.js Normal file
View file

@ -0,0 +1,218 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
const APP_ALLOWLIST = [
'org.freedesktop.impl.portal.desktop.gtk',
'org.freedesktop.impl.portal.desktop.gnome',
];
const INTROSPECT_DBUS_API_VERSION = 3;
import {loadInterfaceXML} from './fileUtils.js';
import {DBusSenderChecker} from './util.js';
const IntrospectDBusIface = loadInterfaceXML('org.gnome.Shell.Introspect');
export class IntrospectService {
constructor() {
this._dbusImpl =
Gio.DBusExportedObject.wrapJSObject(IntrospectDBusIface, this);
this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Introspect');
Gio.DBus.session.own_name('org.gnome.Shell.Introspect',
Gio.BusNameOwnerFlags.REPLACE,
null, null);
this._runningApplications = {};
this._runningApplicationsDirty = true;
this._activeApplication = null;
this._activeApplicationDirty = true;
this._animationsEnabled = true;
this._appSystem = Shell.AppSystem.get_default();
this._appSystem.connect('app-state-changed', () => {
this._runningApplicationsDirty = true;
this._syncRunningApplications();
});
let tracker = Shell.WindowTracker.get_default();
tracker.connect('notify::focus-app', () => {
this._activeApplicationDirty = true;
this._syncRunningApplications();
});
tracker.connect('tracked-windows-changed',
() => this._dbusImpl.emit_signal('WindowsChanged', null));
this._syncRunningApplications();
this._senderChecker = new DBusSenderChecker(APP_ALLOWLIST);
this._settings = St.Settings.get();
this._settings.connect('notify::enable-animations',
this._syncAnimationsEnabled.bind(this));
this._syncAnimationsEnabled();
const monitorManager = global.backend.get_monitor_manager();
monitorManager.connect('monitors-changed',
this._syncScreenSize.bind(this));
this._syncScreenSize();
}
_isStandaloneApp(app) {
return app.get_windows().some(w => w.transient_for == null);
}
_getSandboxedAppId(app) {
let ids = app.get_windows().map(w => w.get_sandboxed_app_id());
return ids.find(id => id != null);
}
_syncRunningApplications() {
let tracker = Shell.WindowTracker.get_default();
let apps = this._appSystem.get_running();
let seatName = 'seat0';
let newRunningApplications = {};
let newActiveApplication = null;
let focusedApp = tracker.focus_app;
for (let app of apps) {
let appInfo = {};
let isAppActive = focusedApp === app;
if (!this._isStandaloneApp(app))
continue;
if (isAppActive) {
appInfo['active-on-seats'] = new GLib.Variant('as', [seatName]);
newActiveApplication = app.get_id();
}
let sandboxedAppId = this._getSandboxedAppId(app);
if (sandboxedAppId)
appInfo['sandboxed-app-id'] = new GLib.Variant('s', sandboxedAppId);
newRunningApplications[app.get_id()] = appInfo;
}
if (this._runningApplicationsDirty ||
(this._activeApplicationDirty &&
this._activeApplication !== newActiveApplication)) {
this._runningApplications = newRunningApplications;
this._activeApplication = newActiveApplication;
this._dbusImpl.emit_signal('RunningApplicationsChanged', null);
}
this._runningApplicationsDirty = false;
this._activeApplicationDirty = false;
}
_isEligibleWindow(window) {
if (window.is_override_redirect())
return false;
let type = window.get_window_type();
return type === Meta.WindowType.NORMAL ||
type === Meta.WindowType.DIALOG ||
type === Meta.WindowType.MODAL_DIALOG ||
type === Meta.WindowType.UTILITY;
}
async GetRunningApplicationsAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
invocation.return_value(new GLib.Variant('(a{sa{sv}})', [this._runningApplications]));
}
async GetWindowsAsync(params, invocation) {
let focusWindow = global.display.get_focus_window();
let apps = this._appSystem.get_running();
let windowsList = {};
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
for (let app of apps) {
let windows = app.get_windows();
for (let window of windows) {
if (!this._isEligibleWindow(window))
continue;
let windowId = window.get_id();
let frameRect = window.get_frame_rect();
let title = window.get_title();
let wmClass = window.get_wm_class();
let sandboxedAppId = window.get_sandboxed_app_id();
windowsList[windowId] = {
'app-id': GLib.Variant.new('s', app.get_id()),
'client-type': GLib.Variant.new('u', window.get_client_type()),
'is-hidden': GLib.Variant.new('b', window.is_hidden()),
'has-focus': GLib.Variant.new('b', window === focusWindow),
'width': GLib.Variant.new('u', frameRect.width),
'height': GLib.Variant.new('u', frameRect.height),
};
// These properties may not be available for all windows:
if (title != null)
windowsList[windowId]['title'] = GLib.Variant.new('s', title);
if (wmClass != null)
windowsList[windowId]['wm-class'] = GLib.Variant.new('s', wmClass);
if (sandboxedAppId != null) {
windowsList[windowId]['sandboxed-app-id'] =
GLib.Variant.new('s', sandboxedAppId);
}
}
}
invocation.return_value(new GLib.Variant('(a{ta{sv}})', [windowsList]));
}
_syncAnimationsEnabled() {
let wasAnimationsEnabled = this._animationsEnabled;
this._animationsEnabled = this._settings.enable_animations;
if (wasAnimationsEnabled !== this._animationsEnabled) {
let variant = new GLib.Variant('b', this._animationsEnabled);
this._dbusImpl.emit_property_changed('AnimationsEnabled', variant);
}
}
_syncScreenSize() {
const oldScreenWidth = this._screenWidth;
const oldScreenHeight = this._screenHeight;
this._screenWidth = global.screen_width;
this._screenHeight = global.screen_height;
if (oldScreenWidth !== this._screenWidth ||
oldScreenHeight !== this._screenHeight) {
const variant = new GLib.Variant('(ii)',
[this._screenWidth, this._screenHeight]);
this._dbusImpl.emit_property_changed('ScreenSize', variant);
}
}
get AnimationsEnabled() {
return this._animationsEnabled;
}
get ScreenSize() {
return [this._screenWidth, this._screenHeight];
}
get version() {
return INTROSPECT_DBUS_API_VERSION;
}
}

302
js/misc/jsParse.js Normal file
View file

@ -0,0 +1,302 @@
const AsyncFunction = async function () {}.constructor;
/**
* Returns a list of potential completions for text. Completions either
* follow a dot (e.g. foo.ba -> bar) or they are picked from globalCompletionList (e.g. fo -> foo)
* commandHeader is prefixed on any expression before it is eval'ed. It will most likely
* consist of global constants that might not carry over from the calling environment.
*
* This function is likely the one you want to call from external modules
*
* @param {string} text
* @param {string} commandHeader
* @param {readonly string[]} [globalCompletionList]
*/
export async function getCompletions(text, commandHeader, globalCompletionList) {
let methods = [];
let expr_, base;
let attrHead = '';
if (globalCompletionList == null)
globalCompletionList = [];
let offset = getExpressionOffset(text, text.length - 1);
if (offset >= 0) {
text = text.slice(offset);
// Look for expressions like "Main.panel.foo" and match Main.panel and foo
let matches = text.match(/(.*)\.(.*)/);
if (matches) {
[expr_, base, attrHead] = matches;
methods = (await getPropertyNamesFromExpression(base, commandHeader)).filter(
attr => attr.slice(0, attrHead.length) === attrHead);
}
// Look for the empty expression or partially entered words
// not proceeded by a dot and match them against global constants
matches = text.match(/^(\w*)$/);
if (text === '' || matches) {
[expr_, attrHead] = matches;
methods = globalCompletionList.filter(
attr => attr.slice(0, attrHead.length) === attrHead);
}
}
return [methods, attrHead];
}
/**
* A few functions for parsing strings of javascript code.
*/
/**
* Identify characters that delimit an expression. That is,
* if we encounter anything that isn't a letter, '.', ')', or ']',
* we should stop parsing.
*
* @param {string} c
*/
function isStopChar(c) {
return !c.match(/[\w.)\]]/);
}
/**
* Given the ending position of a quoted string, find where it starts
*
* @param {string} expr
* @param {number} offset
*/
export function findMatchingQuote(expr, offset) {
let quoteChar = expr.charAt(offset);
for (let i = offset - 1; i >= 0; --i) {
if (expr.charAt(i) === quoteChar && expr.charAt(i - 1) !== '\\')
return i;
}
return -1;
}
/**
* Given the ending position of a regex, find where it starts
*
* @param {string} expr
* @param {number} offset
*/
export function findMatchingSlash(expr, offset) {
for (let i = offset - 1; i >= 0; --i) {
if (expr.charAt(i) === '/' && expr.charAt(i - 1) !== '\\')
return i;
}
return -1;
}
/**
* If expr.charAt(offset) is ')' or ']',
* return the position of the corresponding '(' or '[' bracket.
* This function does not check for syntactic correctness. e.g.,
* findMatchingBrace("[(])", 3) returns 1.
*
* @param {string} expr
* @param {number} offset
*/
export function findMatchingBrace(expr, offset) {
let closeBrace = expr.charAt(offset);
let openBrace = {')': '(', ']': '['}[closeBrace];
return findTheBrace(expr, offset - 1, openBrace, closeBrace);
}
/**
* @param {*} expr
* @param {*} offset
* @param {...any} braces
* @returns {number}
*/
export function findTheBrace(expr, offset, ...braces) {
let [openBrace, closeBrace] = braces;
if (offset < 0)
return -1;
if (expr.charAt(offset) === openBrace)
return offset;
if (expr.charAt(offset).match(/['"]/))
return findTheBrace(expr, findMatchingQuote(expr, offset) - 1, ...braces);
if (expr.charAt(offset) === '/')
return findTheBrace(expr, findMatchingSlash(expr, offset) - 1, ...braces);
if (expr.charAt(offset) === closeBrace)
return findTheBrace(expr, findTheBrace(expr, offset - 1, ...braces) - 1, ...braces);
return findTheBrace(expr, offset - 1, ...braces);
}
/**
* Walk expr backwards from offset looking for the beginning of an
* expression suitable for passing to eval.
* There is no guarantee of correct javascript syntax between the return
* value and offset. This function is meant to take a string like
* "foo(Obj.We.Are.Completing" and allow you to extract "Obj.We.Are.Completing"
*
* @param {string} expr
* @param {number} offset
*/
export function getExpressionOffset(expr, offset) {
while (offset >= 0) {
let currChar = expr.charAt(offset);
if (isStopChar(currChar))
return offset + 1;
if (currChar.match(/[)\]]/))
offset = findMatchingBrace(expr, offset);
--offset;
}
return offset + 1;
}
/**
* Things with non-word characters or that start with a number
* are not accessible via .foo notation and so aren't returned
*
* @param {string} w
*/
function isValidPropertyName(w) {
return !(w.match(/\W/) || w.match(/^\d/));
}
/**
* To get all properties (enumerable and not), we need to walk
* the prototype chain ourselves
*
* @param {object} obj
*/
export function getAllProps(obj) {
if (obj === null || obj === undefined)
return [];
return Object.getOwnPropertyNames(obj).concat(getAllProps(Object.getPrototypeOf(obj)));
}
/**
* Given a string _expr_, returns all methods
* that can be accessed via '.' notation.
* e.g., expr="({ foo: null, bar: null, 4: null })" will
* return ["foo", "bar", ...] but the list will not include "4",
* since methods accessed with '.' notation must star with a letter or _.
*
* @param {string} expr
* @param {string=} commandHeader
*/
export async function getPropertyNamesFromExpression(expr, commandHeader = '') {
let obj = {};
if (!isUnsafeExpression(expr)) {
try {
const lines = expr.split('\n');
lines.push(`return ${lines.pop()}`);
obj = await AsyncFunction(commandHeader + lines.join(';'))();
} catch {
return [];
}
} else {
return [];
}
let propsUnique = {};
if (typeof obj === 'object') {
let allProps = getAllProps(obj);
// Get only things we are allowed to complete following a '.'
allProps = allProps.filter(isValidPropertyName);
// Make sure propsUnique contains one key for every
// property so we end up with a unique list of properties
allProps.map(p => (propsUnique[p] = null));
}
return Object.keys(propsUnique).sort();
}
/**
* Given a list of words, returns the longest prefix they all have in common
*
* @param {readonly string[]} words
*/
export function getCommonPrefix(words) {
let word = words[0];
for (let i = 0; i < word.length; i++) {
for (let w = 1; w < words.length; w++) {
if (words[w].charAt(i) !== word.charAt(i))
return word.slice(0, i);
}
}
return word;
}
/**
* Remove any blocks that are quoted or are in a regex
*
* @param {string} str
*/
export function removeLiterals(str) {
if (str.length === 0)
return '';
let currChar = str.charAt(str.length - 1);
if (currChar === '"' || currChar === '\'') {
return removeLiterals(
str.slice(0, findMatchingQuote(str, str.length - 1)));
} else if (currChar === '/') {
return removeLiterals(
str.slice(0, findMatchingSlash(str, str.length - 1)));
}
return removeLiterals(str.slice(0, str.length - 1)) + currChar;
}
/**
* Returns true if there is reason to think that eval(str)
* will modify the global scope
*
* @param {string} str
*/
export function isUnsafeExpression(str) {
// Check for any sort of assignment
// The strategy used is dumb: remove any quotes
// or regexs and comparison operators and see if there is an '=' character.
// If there is, it might be an unsafe assignment.
let prunedStr = removeLiterals(str);
prunedStr = prunedStr.replace(/[=!]==/g, ''); // replace === and !== with nothing
prunedStr = prunedStr.replace(/[=<>!]=/g, ''); // replace ==, <=, >=, != with nothing
if (prunedStr.match(/[=]/)) {
return true;
} else if (prunedStr.match(/;/)) {
// If we contain a semicolon not inside of a quote/regex, assume we're unsafe as well
return true;
}
return false;
}
/**
* Returns a list of global keywords derived from str
*
* @param {string} str
*/
export function getDeclaredConstants(str) {
let ret = [];
str.split(';').forEach(s => {
let base_, keyword;
let match = s.match(/const\s+(\w+)\s*=/);
if (match) {
[base_, keyword] = match;
ret.push(keyword);
}
});
return ret;
}

173
js/misc/keyboardManager.js Normal file
View file

@ -0,0 +1,173 @@
import GLib from 'gi://GLib';
import GnomeDesktop from 'gi://GnomeDesktop';
import * as Main from '../ui/main.js';
export const DEFAULT_LOCALE = 'en_US';
export const DEFAULT_LAYOUT = 'us';
export const DEFAULT_VARIANT = '';
let _xkbInfo = null;
/**
* @returns {GnomeDesktop.XkbInfo}
*/
export function getXkbInfo() {
if (_xkbInfo == null)
_xkbInfo = new GnomeDesktop.XkbInfo();
return _xkbInfo;
}
let _keyboardManager = null;
/**
* @returns {KeyboardManager}
*/
export function getKeyboardManager() {
if (_keyboardManager == null)
_keyboardManager = new KeyboardManager();
return _keyboardManager;
}
export function releaseKeyboard() {
if (Main.modalCount > 0)
global.backend.unfreeze_keyboard(global.get_current_time());
else
global.backend.ungrab_keyboard(global.get_current_time());
}
export function holdKeyboard() {
global.backend.freeze_keyboard(global.get_current_time());
}
class KeyboardManager {
constructor() {
// The XKB protocol doesn't allow for more that 4 layouts in a
// keymap. Wayland doesn't impose this limit and libxkbcommon can
// handle up to 32 layouts but since we need to support X clients
// even as a Wayland compositor, we can't bump this.
this.MAX_LAYOUTS_PER_GROUP = 4;
this._xkbInfo = getXkbInfo();
this._current = null;
this._localeLayoutInfo = this._getLocaleLayout();
this._layoutInfos = {};
this._currentKeymap = null;
}
_applyLayoutGroup(group) {
let options = this._buildOptionsString();
let [layouts, variants] = this._buildGroupStrings(group);
let model = this._xkbModel;
if (this._currentKeymap &&
this._currentKeymap.layouts === layouts &&
this._currentKeymap.variants === variants &&
this._currentKeymap.options === options &&
this._currentKeymap.model === model)
return;
this._currentKeymap = {layouts, variants, options, model};
global.backend.set_keymap(layouts, variants, options, model);
}
_applyLayoutGroupIndex(idx) {
global.backend.lock_layout_group(idx);
}
apply(id) {
let info = this._layoutInfos[id];
if (!info)
return;
if (this._current && this._current.group === info.group) {
if (this._current.groupIndex !== info.groupIndex)
this._applyLayoutGroupIndex(info.groupIndex);
} else {
this._applyLayoutGroup(info.group);
this._applyLayoutGroupIndex(info.groupIndex);
}
this._current = info;
}
reapply() {
if (!this._current)
return;
this._applyLayoutGroup(this._current.group);
this._applyLayoutGroupIndex(this._current.groupIndex);
}
setUserLayouts(ids) {
this._current = null;
this._layoutInfos = {};
for (let i = 0; i < ids.length; ++i) {
let [found, , , _layout, _variant] = this._xkbInfo.get_layout_info(ids[i]);
if (found)
this._layoutInfos[ids[i]] = {id: ids[i], layout: _layout, variant: _variant};
}
let i = 0;
let group = [];
for (let id in this._layoutInfos) {
// We need to leave one slot on each group free so that we
// can add a layout containing the symbols for the
// language used in UI strings to ensure that toolkits can
// handle mnemonics like Alt+Ф even if the user is
// actually typing in a different layout.
let groupIndex = i % (this.MAX_LAYOUTS_PER_GROUP - 1);
if (groupIndex === 0)
group = [];
let info = this._layoutInfos[id];
group[groupIndex] = info;
info.group = group;
info.groupIndex = groupIndex;
i += 1;
}
}
_getLocaleLayout() {
let locale = GLib.get_language_names()[0];
if (!locale.includes('_'))
locale = DEFAULT_LOCALE;
let [found, , id] = GnomeDesktop.get_input_source_from_locale(locale);
if (!found)
[, , id] = GnomeDesktop.get_input_source_from_locale(DEFAULT_LOCALE);
let _layout, _variant;
[found, , , _layout, _variant] = this._xkbInfo.get_layout_info(id);
if (found)
return {layout: _layout, variant: _variant};
else
return {layout: DEFAULT_LAYOUT, variant: DEFAULT_VARIANT};
}
_buildGroupStrings(_group) {
let group = _group.concat(this._localeLayoutInfo);
let layouts = group.map(g => g.layout).join(',');
let variants = group.map(g => g.variant).join(',');
return [layouts, variants];
}
setKeyboardOptions(options) {
this._xkbOptions = options;
}
setKeyboardModel(model) {
this._xkbModel = model;
}
_buildOptionsString() {
let options = this._xkbOptions.join(',');
return options;
}
get currentLayout() {
return this._current;
}
}

309
js/misc/loginManager.js Normal file
View file

@ -0,0 +1,309 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GioUnix from 'gi://GioUnix';
import Shell from 'gi://Shell';
import * as Signals from './signals.js';
import {loadInterfaceXML} from './fileUtils.js';
const SystemdLoginManagerIface = loadInterfaceXML('org.freedesktop.login1.Manager');
const SystemdLoginSessionIface = loadInterfaceXML('org.freedesktop.login1.Session');
const SystemdLoginUserIface = loadInterfaceXML('org.freedesktop.login1.User');
const SystemdLoginManager = Gio.DBusProxy.makeProxyWrapper(SystemdLoginManagerIface);
const SystemdLoginSession = Gio.DBusProxy.makeProxyWrapper(SystemdLoginSessionIface);
const SystemdLoginUser = Gio.DBusProxy.makeProxyWrapper(SystemdLoginUserIface);
function haveSystemd() {
return GLib.access('/run/systemd/seats', 0) >= 0;
}
function versionCompare(required, reference) {
required = required.split('.');
reference = reference.split('.');
for (let i = 0; i < required.length; i++) {
let requiredInt = parseInt(required[i]);
let referenceInt = parseInt(reference[i]);
if (requiredInt !== referenceInt)
return requiredInt < referenceInt;
}
return true;
}
/**
* @returns {boolean}
*/
export function canLock() {
try {
let params = GLib.Variant.new('(ss)', ['org.gnome.DisplayManager.Manager', 'Version']);
let result = Gio.DBus.system.call_sync(
'org.gnome.DisplayManager',
'/org/gnome/DisplayManager/Manager',
'org.freedesktop.DBus.Properties',
'Get', params, null,
Gio.DBusCallFlags.NONE,
-1, null);
let version = result.deepUnpack()[0].deepUnpack();
return haveSystemd() && versionCompare('3.5.91', version);
} catch {
return false;
}
}
export async function registerSessionWithGDM() {
log('Registering session with GDM');
try {
await Gio.DBus.system.call(
'org.gnome.DisplayManager',
'/org/gnome/DisplayManager/Manager',
'org.gnome.DisplayManager.Manager',
'RegisterSession',
GLib.Variant.new('(a{sv})', [{}]), null,
Gio.DBusCallFlags.NONE, -1, null);
} catch (e) {
if (!e.matches(Gio.DBusError, Gio.DBusError.UNKNOWN_METHOD))
log(`Error registering session with GDM: ${e.message}`);
else
log('Not calling RegisterSession(): method not exported, GDM too old?');
}
}
let _loginManager = null;
/**
* An abstraction over systemd/logind and ConsoleKit.
*
* @returns {LoginManagerSystemd | LoginManagerDummy} - the LoginManager singleton
*/
export function getLoginManager() {
if (_loginManager == null) {
if (haveSystemd())
_loginManager = new LoginManagerSystemd();
else
_loginManager = new LoginManagerDummy();
}
return _loginManager;
}
class LoginManagerSystemd extends Signals.EventEmitter {
constructor() {
super();
this._preparingForSleep = false;
this._proxy = new SystemdLoginManager(Gio.DBus.system,
'org.freedesktop.login1',
'/org/freedesktop/login1');
this._proxy.connectSignal('PrepareForSleep',
this._prepareForSleep.bind(this));
this._proxy.connectSignal('SessionRemoved',
this._sessionRemoved.bind(this));
}
async getCurrentUserProxy() {
if (this._userProxy)
return this._userProxy;
const uid = Shell.util_get_uid();
try {
const [objectPath] = await this._proxy.GetUserAsync(uid);
this._userProxy = await SystemdLoginUser.newAsync(
Gio.DBus.system, 'org.freedesktop.login1', objectPath);
return this._userProxy;
} catch (error) {
logError(error, `Could not get a proxy for user ${uid}`);
return null;
}
}
async getCurrentSessionProxy() {
if (this._currentSession)
return this._currentSession;
let sessionId = GLib.getenv('XDG_SESSION_ID');
if (!sessionId) {
log('Unset XDG_SESSION_ID, getCurrentSessionProxy() called outside a user session. Asking logind directly.');
const userProxy = await this.getCurrentUserProxy();
let [session, objectPath] = userProxy.Display;
if (session) {
log(`Will monitor session ${session}`);
sessionId = session;
} else {
log('Failed to find "Display" session; are we the greeter?');
for ([session, objectPath] of userProxy.Sessions) {
let sessionProxy = new SystemdLoginSession(Gio.DBus.system,
'org.freedesktop.login1',
objectPath);
log(`Considering ${session}, class=${sessionProxy.Class}`);
if (sessionProxy.Class === 'greeter') {
log(`Yes, will monitor session ${session}`);
sessionId = session;
break;
}
}
if (!sessionId) {
log('No, failed to get session from logind.');
return null;
}
}
}
try {
const [objectPath] = await this._proxy.GetSessionAsync(sessionId);
this._currentSession = await SystemdLoginSession.newAsync(
Gio.DBus.system, 'org.freedesktop.login1', objectPath);
return this._currentSession;
} catch (error) {
logError(error, 'Could not get a proxy for the current session');
return null;
}
}
async canSuspend() {
let canSuspend, needsAuth;
try {
const [result] = await this._proxy.CanSuspendAsync();
needsAuth = result === 'challenge';
canSuspend = needsAuth || result === 'yes';
} catch {
canSuspend = false;
needsAuth = false;
}
return {canSuspend, needsAuth};
}
async canRebootToBootLoaderMenu() {
let canRebootToBootLoaderMenu, needsAuth;
try {
const [result] = await this._proxy.CanRebootToBootLoaderMenuAsync();
needsAuth = result === 'challenge';
canRebootToBootLoaderMenu = needsAuth || result === 'yes';
} catch {
canRebootToBootLoaderMenu = false;
needsAuth = false;
}
return {canRebootToBootLoaderMenu, needsAuth};
}
setRebootToBootLoaderMenu() {
/* Parameter is timeout in usec, show to menu for 60 seconds */
this._proxy.SetRebootToBootLoaderMenuAsync(60000000);
}
async listSessions() {
try {
const [sessions] = await this._proxy.ListSessionsAsync();
return sessions;
} catch {
return [];
}
}
getSession(objectPath) {
return new SystemdLoginSession(Gio.DBus.system, 'org.freedesktop.login1', objectPath);
}
suspend() {
this._proxy.SuspendAsync(true);
}
async inhibit(reason, cancellable) {
const inVariant = new GLib.Variant('(ssss)',
['sleep', 'GNOME Shell', reason, 'delay']);
const [outVariant_, fdList] =
await this._proxy.call_with_unix_fd_list('Inhibit',
inVariant, 0, -1, null, cancellable);
const [fd] = fdList.steal_fds();
return new GioUnix.InputStream({fd});
}
_prepareForSleep(proxy, sender, [aboutToSuspend]) {
this._preparingForSleep = aboutToSuspend;
this.emit('prepare-for-sleep', aboutToSuspend);
}
/**
* Whether the machine is preparing to sleep.
*
* This is true between paired emissions of `prepare-for-sleep`.
*
* @type {boolean}
*/
get preparingForSleep() {
return this._preparingForSleep;
}
_sessionRemoved(proxy, sender, [sessionId]) {
this.emit('session-removed', sessionId);
}
}
class LoginManagerDummy extends Signals.EventEmitter {
constructor() {
super();
this._preparingForSleep = false;
}
getCurrentUserProxy() {
// we could return a DummyUser object that fakes whatever callers
// expect, but just never settling the promise should be safer
return new Promise(() => {});
}
getCurrentSessionProxy() {
// we could return a DummySession object that fakes whatever callers
// expect (at the time of writing: connect() and connectSignal()
// methods), but just never settling the promise should be safer
return new Promise(() => {});
}
canSuspend() {
return new Promise(resolve => resolve({
canSuspend: false,
needsAuth: false,
}));
}
canRebootToBootLoaderMenu() {
return new Promise(resolve => resolve({
canRebootToBootLoaderMenu: false,
needsAuth: false,
}));
}
setRebootToBootLoaderMenu() {
}
listSessions() {
return new Promise(resolve => resolve([]));
}
getSession(_objectPath) {
return null;
}
suspend() {
this._preparingForSleep = true;
this.emit('prepare-for-sleep', true);
this._preparingForSleep = false;
this.emit('prepare-for-sleep', false);
}
get preparingForSleep() {
return this._preparingForSleep;
}
/* eslint-disable-next-line require-await */
async inhibit() {
return null;
}
}

23
js/misc/meson.build Normal file
View file

@ -0,0 +1,23 @@
jsconf = configuration_data()
jsconf.set('PACKAGE_NAME', meson.project_name())
jsconf.set('PACKAGE_VERSION', meson.project_version())
jsconf.set('GETTEXT_PACKAGE', meson.project_name())
jsconf.set('LIBMUTTER_API_VERSION', mutter_api_version)
jsconf.set10('HAVE_NETWORKMANAGER', have_networkmanager)
jsconf.set10('HAVE_PORTAL_HELPER', have_portal_helper)
jsconf.set('UTILS_FOLDER_APPS', run_command(
generate_app_list, '../../data/default-apps/utilities-folder.txt',
check: true,
).stdout())
jsconf.set('SYSTEM_FOLDER_APPS', run_command(
generate_app_list, '../../data/default-apps/system-folder.txt',
check: true,
).stdout())
jsconf.set('datadir', datadir)
jsconf.set('libexecdir', libexecdir)
config_js = configure_file(
input: 'config.js.in',
output: 'config.js',
configuration: jsconf
)

302
js/misc/modemManager.js Normal file
View file

@ -0,0 +1,302 @@
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import NM from 'gi://NM';
import NMA4 from 'gi://NMA4';
import {loadInterfaceXML} from './fileUtils.js';
let _mpd;
/**
* _getMobileProvidersDatabase:
*
* Gets the database of mobile providers, with references between MCCMNC/SID and
* operator name
*
* @returns {NMA4.MobileProvidersDatabase | null}
*/
function _getMobileProvidersDatabase() {
if (_mpd == null) {
try {
_mpd = new NMA4.MobileProvidersDatabase();
_mpd.init(null);
} catch (e) {
log(e.message);
_mpd = null;
}
}
return _mpd;
}
// _findProviderForMccMnc:
// @operatorName: operator name
// @operatorCode: operator code
//
// Given an operator name string (which may not be a real operator name) and an
// operator code string, tries to find a proper operator name to display.
//
function _findProviderForMccMnc(operatorName, operatorCode) {
if (operatorName) {
if (operatorName.length !== 0 &&
(operatorName.length > 6 || operatorName.length < 5)) {
// this looks like a valid name, i.e. not an MCCMNC (that some
// devices return when not yet connected
return operatorName;
}
if (isNaN(parseInt(operatorName))) {
// name is definitely not a MCCMNC, so it may be a name
// after all; return that
return operatorName;
}
}
let needle;
if ((!operatorName || operatorName.length === 0) && operatorCode)
needle = operatorCode;
else if (operatorName && (operatorName.length === 6 || operatorName.length === 5))
needle = operatorName;
else // nothing to search
return null;
let mpd = _getMobileProvidersDatabase();
if (mpd) {
let provider = mpd.lookup_3gpp_mcc_mnc(needle);
if (provider)
return provider.get_name();
}
return null;
}
// _findProviderForSid:
// @sid: System Identifier of the serving CDMA network
//
// Tries to find the operator name corresponding to the given SID
//
function _findProviderForSid(sid) {
if (!sid)
return null;
let mpd = _getMobileProvidersDatabase();
if (mpd) {
let provider = mpd.lookup_cdma_sid(sid);
if (provider)
return provider.get_name();
}
return null;
}
// ----------------------------------------------------- //
// Support for the old ModemManager interface (MM < 0.7) //
// ----------------------------------------------------- //
// The following are not the complete interfaces, just the methods we need
// (or may need in the future)
const ModemGsmNetworkInterface = loadInterfaceXML('org.freedesktop.ModemManager.Modem.Gsm.Network');
const ModemGsmNetworkProxy = Gio.DBusProxy.makeProxyWrapper(ModemGsmNetworkInterface);
const ModemCdmaInterface = loadInterfaceXML('org.freedesktop.ModemManager.Modem.Cdma');
const ModemCdmaProxy = Gio.DBusProxy.makeProxyWrapper(ModemCdmaInterface);
const ModemBase = GObject.registerClass({
GTypeFlags: GObject.TypeFlags.ABSTRACT,
Properties: {
'operator-name': GObject.ParamSpec.string(
'operator-name', null, null,
GObject.ParamFlags.READABLE,
null),
'signal-quality': GObject.ParamSpec.int(
'signal-quality', null, null,
GObject.ParamFlags.READABLE,
0, 100, 0),
},
}, class ModemBase extends GObject.Object {
_init() {
super._init();
this._operatorName = null;
this._signalQuality = 0;
}
get operatorName() {
return this._operatorName;
}
get signalQuality() {
return this._signalQuality;
}
_setOperatorName(operatorName) {
if (this._operatorName === operatorName)
return;
this._operatorName = operatorName;
this.notify('operator-name');
}
_setSignalQuality(signalQuality) {
if (this._signalQuality === signalQuality)
return;
this._signalQuality = signalQuality;
this.notify('signal-quality');
}
});
export const ModemGsm = GObject.registerClass(
class ModemGsm extends ModemBase {
_init(path) {
super._init();
this._proxy = new ModemGsmNetworkProxy(Gio.DBus.system, 'org.freedesktop.ModemManager', path);
// Code is duplicated because the function have different signatures
this._proxy.connectSignal('SignalQuality', (proxy, sender, [quality]) => {
this._setSignalQuality(quality);
});
this._proxy.connectSignal('RegistrationInfo', (proxy, sender, [_status, code, name]) => {
this._setOperatorName(_findProviderForMccMnc(name, code));
});
this._getInitialState();
}
async _getInitialState() {
try {
const [
[status_, code, name],
[quality],
] = await Promise.all([
this._proxy.GetRegistrationInfoAsync(),
this._proxy.GetSignalQualityAsync(),
]);
this._setOperatorName(_findProviderForMccMnc(name, code));
this._setSignalQuality(quality);
} catch {
// it will return an error if the device is not connected
this._setSignalQuality(0);
}
}
});
export const ModemCdma = GObject.registerClass(
class ModemCdma extends ModemBase {
_init(path) {
super._init();
this._proxy = new ModemCdmaProxy(Gio.DBus.system, 'org.freedesktop.ModemManager', path);
this._proxy.connectSignal('SignalQuality', (proxy, sender, params) => {
this._setSignalQuality(params[0]);
// receiving this signal means the device got activated
// and we can finally call GetServingSystem
if (this.operator_name == null)
this._refreshServingSystem();
});
this._getSignalQuality();
}
async _getSignalQuality() {
try {
const [quality] = await this._proxy.GetSignalQualityAsync();
this._setSignalQuality(quality);
} catch {
// it will return an error if the device is not connected
this._setSignalQuality(0);
}
}
async _refreshServingSystem() {
try {
const [bandClass_, band_, sid] =
await this._proxy.GetServingSystemAsync();
this._setOperatorName(_findProviderForSid(sid));
} catch {
// it will return an error if the device is not connected
this._setOperatorName(null);
}
}
});
// ------------------------------------------------------- //
// Support for the new ModemManager1 interface (MM >= 0.7) //
// ------------------------------------------------------- //
const BroadbandModemInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem');
const BroadbandModemProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModemInterface);
const BroadbandModem3gppInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem.Modem3gpp');
const BroadbandModem3gppProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModem3gppInterface);
const BroadbandModemCdmaInterface = loadInterfaceXML('org.freedesktop.ModemManager1.Modem.ModemCdma');
const BroadbandModemCdmaProxy = Gio.DBusProxy.makeProxyWrapper(BroadbandModemCdmaInterface);
export const BroadbandModem = GObject.registerClass({
Properties: {
'capabilities': GObject.ParamSpec.flags(
'capabilities', null, null,
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
NM.DeviceModemCapabilities.$gtype,
NM.DeviceModemCapabilities.NONE),
},
}, class BroadbandModem extends ModemBase {
_init(path, capabilities) {
super._init({capabilities});
this._proxy = new BroadbandModemProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
this._proxy_3gpp = new BroadbandModem3gppProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
this._proxy_cdma = new BroadbandModemCdmaProxy(Gio.DBus.system, 'org.freedesktop.ModemManager1', path);
this._proxy.connect('g-properties-changed', (proxy, properties) => {
const signalQualityChanged = !!properties.lookup_value('SignalQuality', null);
if (signalQualityChanged)
this._reloadSignalQuality();
});
this._reloadSignalQuality();
this._proxy_3gpp.connect('g-properties-changed', (proxy, properties) => {
let unpacked = properties.deepUnpack();
if ('OperatorName' in unpacked || 'OperatorCode' in unpacked)
this._reload3gppOperatorName();
});
this._reload3gppOperatorName();
this._proxy_cdma.connect('g-properties-changed', (proxy, properties) => {
let unpacked = properties.deepUnpack();
if ('Nid' in unpacked || 'Sid' in unpacked)
this._reloadCdmaOperatorName();
});
this._reloadCdmaOperatorName();
}
_reloadSignalQuality() {
let [quality, recent_] = this._proxy.SignalQuality;
this._setSignalQuality(quality);
}
_reloadOperatorName() {
let newName = '';
if (this.operator_name_3gpp && this.operator_name_3gpp.length > 0)
newName += this.operator_name_3gpp;
if (this.operator_name_cdma && this.operator_name_cdma.length > 0) {
if (newName !== '')
newName += ', ';
newName += this.operator_name_cdma;
}
this._setOperatorName(newName);
}
_reload3gppOperatorName() {
let name = this._proxy_3gpp.OperatorName;
let code = this._proxy_3gpp.OperatorCode;
this.operator_name_3gpp = _findProviderForMccMnc(name, code);
this._reloadOperatorName();
}
_reloadCdmaOperatorName() {
let sid = this._proxy_cdma.Sid;
this.operator_name_cdma = _findProviderForSid(sid);
this._reloadOperatorName();
}
});

259
js/misc/objectManager.js Normal file
View file

@ -0,0 +1,259 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as Params from './params.js';
import * as Signals from './signals.js';
// Specified in the D-Bus specification here:
// http://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces-objectmanager
const ObjectManagerIface = `
<node>
<interface name="org.freedesktop.DBus.ObjectManager">
<method name="GetManagedObjects">
<arg name="objects" type="a{oa{sa{sv}}}" direction="out"/>
</method>
<signal name="InterfacesAdded">
<arg name="objectPath" type="o"/>
<arg name="interfaces" type="a{sa{sv}}" />
</signal>
<signal name="InterfacesRemoved">
<arg name="objectPath" type="o"/>
<arg name="interfaces" type="as" />
</signal>
</interface>
</node>`;
const ObjectManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(ObjectManagerIface);
export class ObjectManager extends Signals.EventEmitter {
constructor(params) {
super();
params = Params.parse(params, {
connection: null,
name: null,
objectPath: null,
knownInterfaces: null,
cancellable: null,
onLoaded: null,
});
this._connection = params.connection;
this._serviceName = params.name;
this._managerPath = params.objectPath;
this._cancellable = params.cancellable;
this._managerProxy = new Gio.DBusProxy({
g_connection: this._connection,
g_interface_name: ObjectManagerInfo.name,
g_interface_info: ObjectManagerInfo,
g_name: this._serviceName,
g_object_path: this._managerPath,
g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
});
this._interfaceInfos = {};
this._objects = {};
this._interfaces = {};
this._onLoaded = params.onLoaded;
if (params.knownInterfaces)
this._registerInterfaces(params.knownInterfaces);
this._initManagerProxy();
}
_completeLoad() {
if (this._onLoaded)
this._onLoaded();
}
async _addInterface(objectPath, interfaceName) {
let info = this._interfaceInfos[interfaceName];
if (!info)
return;
const proxy = new Gio.DBusProxy({
g_connection: this._connection,
g_name: this._serviceName,
g_object_path: objectPath,
g_interface_name: interfaceName,
g_interface_info: info,
g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START,
});
try {
await proxy.init_async(GLib.PRIORITY_DEFAULT, this._cancellable);
} catch (e) {
logError(e, `could not initialize proxy for interface ${interfaceName}`);
return;
}
let isNewObject;
if (!this._objects[objectPath]) {
this._objects[objectPath] = {};
isNewObject = true;
} else {
isNewObject = false;
}
this._objects[objectPath][interfaceName] = proxy;
if (!this._interfaces[interfaceName])
this._interfaces[interfaceName] = [];
this._interfaces[interfaceName].push(proxy);
if (isNewObject)
this.emit('object-added', objectPath);
this.emit('interface-added', interfaceName, proxy);
}
_removeInterface(objectPath, interfaceName) {
if (!this._objects[objectPath])
return;
let proxy = this._objects[objectPath][interfaceName];
if (this._interfaces[interfaceName]) {
let index = this._interfaces[interfaceName].indexOf(proxy);
if (index >= 0)
this._interfaces[interfaceName].splice(index, 1);
if (this._interfaces[interfaceName].length === 0)
delete this._interfaces[interfaceName];
}
this.emit('interface-removed', interfaceName, proxy);
delete this._objects[objectPath][interfaceName];
if (Object.keys(this._objects[objectPath]).length === 0) {
delete this._objects[objectPath];
this.emit('object-removed', objectPath);
}
}
async _initManagerProxy() {
try {
await this._managerProxy.init_async(
GLib.PRIORITY_DEFAULT, this._cancellable);
} catch (e) {
logError(e, `could not initialize object manager for object ${this._serviceName}`);
this._completeLoad();
return;
}
this._managerProxy.connectSignal('InterfacesAdded',
(objectManager, sender, [objectPath, interfaces]) => {
let interfaceNames = Object.keys(interfaces);
for (let i = 0; i < interfaceNames.length; i++)
this._addInterface(objectPath, interfaceNames[i]);
});
this._managerProxy.connectSignal('InterfacesRemoved',
(objectManager, sender, [objectPath, interfaceNames]) => {
for (let i = 0; i < interfaceNames.length; i++)
this._removeInterface(objectPath, interfaceNames[i]);
});
if (Object.keys(this._interfaceInfos).length === 0) {
this._completeLoad();
return;
}
this._managerProxy.connect('notify::g-name-owner', () => {
if (this._managerProxy.g_name_owner)
this._onNameAppeared();
else
this._onNameVanished();
});
if (this._managerProxy.g_name_owner)
this._onNameAppeared();
}
async _onNameAppeared() {
try {
const [objects] = await this._managerProxy.GetManagedObjectsAsync();
if (!objects) {
this._completeLoad();
return;
}
const objectPaths = Object.keys(objects);
await Promise.allSettled(objectPaths.flatMap(objectPath => {
const object = objects[objectPath];
const interfaceNames = Object.getOwnPropertyNames(object);
return interfaceNames.map(
ifaceName => this._addInterface(objectPath, ifaceName));
}));
} catch (error) {
logError(error, `could not get remote objects for service ${this._serviceName} path ${this._managerPath}`);
} finally {
this._completeLoad();
}
}
_onNameVanished() {
let objectPaths = Object.keys(this._objects);
for (let i = 0; i < objectPaths.length; i++) {
let objectPath = objectPaths[i];
let object = this._objects[objectPath];
let interfaceNames = Object.keys(object);
for (let j = 0; j < interfaceNames.length; j++) {
let interfaceName = interfaceNames[j];
if (object[interfaceName])
this._removeInterface(objectPath, interfaceName);
}
}
}
_registerInterfaces(interfaces) {
for (let i = 0; i < interfaces.length; i++) {
let info = Gio.DBusInterfaceInfo.new_for_xml(interfaces[i]);
this._interfaceInfos[info.name] = info;
}
}
getProxy(objectPath, interfaceName) {
let object = this._objects[objectPath];
if (!object)
return null;
return object[interfaceName];
}
getProxiesForInterface(interfaceName) {
let proxyList = this._interfaces[interfaceName];
if (!proxyList)
return [];
return proxyList;
}
getAllProxies() {
let proxies = [];
let objectPaths = Object.keys(this._objects);
for (let i = 0; i < objectPaths.length; i++) {
let object = this._objects[objectPaths];
let interfaceNames = Object.keys(object);
for (let j = 0; j < interfaceNames.length; j++) {
let interfaceName = interfaceNames[j];
if (object[interfaceName])
proxies.push(object(interfaceName));
}
}
return proxies;
}
}

27
js/misc/params.js Normal file
View file

@ -0,0 +1,27 @@
/**
* parse:
*
* @param {*} params caller-provided parameter object, or %null
* @param {*} defaults provided defaults object
* @param {boolean} [allowExtras] whether or not to allow properties not in `default`
*
* @summary Examines `params` and fills in default values from `defaults` for
* any properties in `default` that don't appear in `params`. If
* `allowExtras` is not %true, it will throw an error if `params`
* contains any properties that aren't in `defaults`.
*
* If `params` is %null, this returns the values from `defaults`.
*
* @returns a new object, containing the merged parameters from
* `params` and `defaults`
*/
export function parse(params = {}, defaults, allowExtras) {
if (!allowExtras) {
for (let prop in params) {
if (!(prop in defaults))
throw new Error(`Unrecognized parameter "${prop}"`);
}
}
return {...defaults, ...params};
}

View file

@ -0,0 +1,155 @@
//
// Copyright (C) 2018, 2019, 2020 Endless Mobile, Inc.
//
// This is a GNOME Shell component to wrap the interactions over
// D-Bus with the malcontent library.
//
// Licensed under the GNU General Public License Version 2
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
// We require libmalcontent ≥ 0.6.0
const HAVE_MALCONTENT = imports.package.checkSymbol(
'Malcontent', '0', 'ManagerGetValueFlags');
let Malcontent = null;
if (HAVE_MALCONTENT) {
({default: Malcontent} = await import('gi://Malcontent?version=0'));
Gio._promisify(Malcontent.Manager.prototype, 'get_app_filter_async');
}
let _singleton = null;
/**
* @returns {ParentalControlsManager}
*/
export function getDefault() {
if (_singleton === null)
_singleton = new ParentalControlsManager();
return _singleton;
}
// A manager class which provides cached access to the constructing users
// parental controls settings. Its possible for the users parental controls
// to change at runtime if the Parental Controls application is used by an
// administrator from within the users session.
const ParentalControlsManager = GObject.registerClass({
Signals: {
'app-filter-changed': {},
},
}, class ParentalControlsManager extends GObject.Object {
_init() {
super._init();
this._initialized = false;
this._disabled = false;
this._appFilter = null;
this._initializeManager();
}
async _initializeManager() {
if (!HAVE_MALCONTENT) {
console.debug('Skipping parental controls support, malcontent not found');
this._initialized = true;
this.emit('app-filter-changed');
return;
}
try {
const connection = await Gio.DBus.get(Gio.BusType.SYSTEM, null);
this._manager = new Malcontent.Manager({connection});
this._appFilter = await this._getAppFilter();
} catch (e) {
logError(e, 'Failed to get parental controls settings');
return;
}
this._manager.connect('app-filter-changed', this._onAppFilterChanged.bind(this));
// Signal initialisation is complete.
this._initialized = true;
this.emit('app-filter-changed');
}
async _getAppFilter() {
let appFilter = null;
try {
appFilter = await this._manager.get_app_filter_async(
Shell.util_get_uid(),
Malcontent.ManagerGetValueFlags.NONE,
null);
} catch (e) {
if (!e.matches(Malcontent.ManagerError, Malcontent.ManagerError.DISABLED))
throw e;
console.debug('Parental controls globally disabled');
this._disabled = true;
}
return appFilter;
}
async _onAppFilterChanged(manager, uid) {
// Emit 'changed' signal only if app-filter is changed for currently logged-in user.
let currentUid = Shell.util_get_uid();
if (currentUid !== uid)
return;
try {
this._appFilter = await this._getAppFilter();
this.emit('app-filter-changed');
} catch (e) {
// Log an error and keep the old app filter.
logError(e, `Failed to get new MctAppFilter for uid ${Shell.util_get_uid()} on app-filter-changed`);
}
}
get initialized() {
return this._initialized;
}
// Calculate whether the given app (a Gio.DesktopAppInfo) should be shown
// on the desktop, in search results, etc. The app should be shown if:
// - The .desktop file doesnt say it should be hidden.
// - The executable from the .desktop files Exec line isnt denied in
// the users parental controls.
// - None of the flatpak app IDs from the X-Flatpak and the
// X-Flatpak-RenamedFrom lines are denied in the users parental
// controls.
shouldShowApp(appInfo) {
// Quick decision?
if (!appInfo.should_show())
return false;
// Are parental controls enabled (at configure time or runtime)?
if (!HAVE_MALCONTENT || this._disabled)
return true;
// Have we finished initialising yet?
if (!this.initialized) {
console.debug(`Hiding app because parental controls not yet initialised: ${appInfo.get_id()}`);
return false;
}
return this._appFilter.is_appinfo_allowed(appInfo);
}
});

View file

@ -0,0 +1,18 @@
import Gio from 'gi://Gio';
import {loadInterfaceXML} from './fileUtils.js';
const PermissionStoreIface = loadInterfaceXML('org.freedesktop.impl.portal.PermissionStore');
const PermissionStoreProxy = Gio.DBusProxy.makeProxyWrapper(PermissionStoreIface);
/**
* @param {Function} initCallback
* @param {Gio.Cancellable} cancellable
* @returns {Gio.DBusProxy}
*/
export function PermissionStore(initCallback, cancellable) {
return new PermissionStoreProxy(Gio.DBus.session,
'org.freedesktop.impl.portal.PermissionStore',
'/org/freedesktop/impl/portal/PermissionStore',
initCallback, cancellable);
}

274
js/misc/signalTracker.js Normal file
View file

@ -0,0 +1,274 @@
import GObject from 'gi://GObject';
const destroyableTypes = [];
/**
* @private
* @param {object} obj - an object
* @returns {bool} - true if obj has a 'destroy' GObject signal
*/
function _hasDestroySignal(obj) {
return destroyableTypes.some(type => obj instanceof type);
}
export const TransientSignalHolder = GObject.registerClass(
class TransientSignalHolder extends GObject.Object {
static [GObject.signals] = {
'destroy': {},
};
constructor(owner) {
super();
if (_hasDestroySignal(owner))
owner.connectObject('destroy', () => this.destroy(), this);
}
destroy() {
this.emit('destroy');
}
});
registerDestroyableType(TransientSignalHolder);
class SignalManager {
/**
* @returns {SignalManager} - the SignalManager singleton
*/
static getDefault() {
if (!this._singleton)
this._singleton = new SignalManager();
return this._singleton;
}
constructor() {
this._signalTrackers = new Map();
global.connect_after('shutdown', () => {
[...this._signalTrackers.values()].forEach(
tracker => tracker.destroy());
this._signalTrackers.clear();
});
}
/**
* @param {object} obj - object to get signal tracker for
* @returns {SignalTracker} - the signal tracker for object
*/
getSignalTracker(obj) {
let signalTracker = this._signalTrackers.get(obj);
if (signalTracker === undefined) {
signalTracker = new SignalTracker(obj);
this._signalTrackers.set(obj, signalTracker);
}
return signalTracker;
}
/**
* @param {object} obj - object to get signal tracker for
* @returns {?SignalTracker} - the signal tracker for object if it exists
*/
maybeGetSignalTracker(obj) {
return this._signalTrackers.get(obj) ?? null;
}
/*
* @param {object} obj - object to remove signal tracker for
* @returns {void}
*/
removeSignalTracker(obj) {
this._signalTrackers.delete(obj);
}
}
class SignalTracker {
/**
* @param {object=} owner - object that owns the tracker
*/
constructor(owner) {
if (_hasDestroySignal(owner))
this._ownerDestroyId = owner.connect_after('destroy', () => this.clear());
this._owner = owner;
this._map = new Map();
}
/**
* @typedef SignalData
* @property {number[]} ownerSignals - a list of handler IDs
* @property {number} destroyId - destroy handler ID of tracked object
*/
/**
* @private
* @param {object} obj - a tracked object
* @returns {SignalData} - signal data for object
*/
_getSignalData(obj) {
let data = this._map.get(obj);
if (data === undefined) {
data = {ownerSignals: [], destroyId: 0};
this._map.set(obj, data);
}
return data;
}
/**
* @private
* @param {GObject.Object} obj - tracked widget
*/
_trackDestroy(obj) {
const signalData = this._getSignalData(obj);
if (signalData.destroyId)
return;
signalData.destroyId = obj.connect_after('destroy', () => this.untrack(obj));
}
_disconnectSignalForProto(proto, obj, id) {
proto['disconnect'].call(obj, id);
}
_getObjectProto(obj) {
return obj instanceof GObject.Object
? GObject.Object.prototype
: Object.getPrototypeOf(obj);
}
_disconnectSignal(obj, id) {
this._disconnectSignalForProto(this._getObjectProto(obj), obj, id);
}
_removeTracker() {
if (this._ownerDestroyId)
this._disconnectSignal(this._owner, this._ownerDestroyId);
SignalManager.getDefault().removeSignalTracker(this._owner);
delete this._ownerDestroyId;
delete this._owner;
}
/**
* @param {object} obj - tracked object
* @param {...number} handlerIds - tracked handler IDs
* @returns {void}
*/
track(obj, ...handlerIds) {
if (_hasDestroySignal(obj))
this._trackDestroy(obj);
this._getSignalData(obj).ownerSignals.push(...handlerIds);
}
/**
* @param {object} obj - tracked object instance
* @returns {void}
*/
untrack(obj) {
const {ownerSignals, destroyId} = this._getSignalData(obj);
this._map.delete(obj);
const ownerProto = this._getObjectProto(this._owner);
ownerSignals.forEach(id =>
this._disconnectSignalForProto(ownerProto, this._owner, id));
if (destroyId)
this._disconnectSignal(obj, destroyId);
if (this._map.size === 0)
this._removeTracker();
}
/**
* @returns {void}
*/
clear() {
this._map.forEach((_, obj) => this.untrack(obj));
}
/**
* @returns {void}
*/
destroy() {
this.clear();
this._removeTracker();
}
}
/**
* Connect one or more signals, and associate the handlers
* with a tracked object.
*
* All handlers for a particular object can be disconnected
* by calling disconnectObject(). If object is a {Clutter.widget},
* this is done automatically when the widget is destroyed.
*
* @param {object} thisObj - the emitter object
* @param {...any} args - a sequence of signal-name/handler pairs
* with an optional flags value, followed by an object to track
* @returns {void}
*/
export function connectObject(thisObj, ...args) {
const getParams = argArray => {
const [signalName, handler, arg, ...rest] = argArray;
if (typeof arg !== 'number')
return [signalName, handler, 0, arg, ...rest];
const flags = arg;
let flagsMask = 0;
Object.values(GObject.ConnectFlags).forEach(v => (flagsMask |= v));
if (!(flags & flagsMask))
throw new Error(`Invalid flag value ${flags}`);
if (flags & GObject.ConnectFlags.SWAPPED)
throw new Error('Swapped signals are not supported');
return [signalName, handler, flags, ...rest];
};
const connectSignal = (emitter, signalName, handler, flags) => {
const isGObject = emitter instanceof GObject.Object;
const func = (flags & GObject.ConnectFlags.AFTER) && isGObject
? 'connect_after'
: 'connect';
const emitterProto = isGObject
? GObject.Object.prototype
: Object.getPrototypeOf(emitter);
return emitterProto[func].call(emitter, signalName, handler);
};
const signalIds = [];
while (args.length > 1) {
const [signalName, handler, flags, ...rest] = getParams(args);
signalIds.push(connectSignal(thisObj, signalName, handler, flags));
args = rest;
}
const obj = args.at(0) ?? globalThis;
const tracker = SignalManager.getDefault().getSignalTracker(thisObj);
tracker.track(obj, ...signalIds);
}
/**
* Disconnect all signals that were connected for
* the specified tracked object
*
* @param {object} thisObj - the emitter object
* @param {object} obj - the tracked object
* @returns {void}
*/
export function disconnectObject(thisObj, obj) {
SignalManager.getDefault().maybeGetSignalTracker(thisObj)?.untrack(obj);
}
/**
* Register a GObject type as having a 'destroy' signal
* that should disconnect all handlers
*
* @param {GObject.Type} gtype - a GObject type
*/
export function registerDestroyableType(gtype) {
if (!GObject.type_is_a(gtype, GObject.Object))
throw new Error(`${gtype} is not a GObject subclass`);
if (!GObject.signal_lookup('destroy', gtype))
throw new Error(`${gtype} does not have a destroy signal`);
destroyableTypes.push(gtype);
}

23
js/misc/signals.js Normal file
View file

@ -0,0 +1,23 @@
import * as SignalTracker from './signalTracker.js';
const Signals = imports.signals;
export class EventEmitter {
connectObject(...args) {
return SignalTracker.connectObject(this, ...args);
}
disconnectObject(...args) {
return SignalTracker.disconnectObject(this, ...args);
}
connect_object(...args) {
return this.connectObject(...args);
}
disconnect_object(...args) {
return this.disconnectObject(...args);
}
}
Signals.addSignalMethods(EventEmitter.prototype);

119
js/misc/smartcardManager.js Normal file
View file

@ -0,0 +1,119 @@
import Gio from 'gi://Gio';
import * as Signals from './signals.js';
import * as ObjectManager from './objectManager.js';
const SmartcardTokenIface = `
<node>
<interface name="org.gnome.SettingsDaemon.Smartcard.Token">
<property name="Name" type="s" access="read"/>
<property name="Driver" type="o" access="read"/>
<property name="IsInserted" type="b" access="read"/>
<property name="UsedToLogin" type="b" access="read"/>
</interface>
</node>`;
let _smartcardManager = null;
/**
* @returns {SmartcardManager}
*/
export function getSmartcardManager() {
if (_smartcardManager == null)
_smartcardManager = new SmartcardManager();
return _smartcardManager;
}
class SmartcardManager extends Signals.EventEmitter {
constructor() {
super();
this._objectManager = new ObjectManager.ObjectManager({
connection: Gio.DBus.session,
name: 'org.gnome.SettingsDaemon.Smartcard',
objectPath: '/org/gnome/SettingsDaemon/Smartcard',
knownInterfaces: [SmartcardTokenIface],
onLoaded: this._onLoaded.bind(this),
});
this._insertedTokens = {};
this._loginToken = null;
}
_onLoaded() {
let tokens = this._objectManager.getProxiesForInterface('org.gnome.SettingsDaemon.Smartcard.Token');
for (let i = 0; i < tokens.length; i++)
this._addToken(tokens[i]);
this._objectManager.connect('interface-added', (objectManager, interfaceName, proxy) => {
if (interfaceName === 'org.gnome.SettingsDaemon.Smartcard.Token')
this._addToken(proxy);
});
this._objectManager.connect('interface-removed', (objectManager, interfaceName, proxy) => {
if (interfaceName === 'org.gnome.SettingsDaemon.Smartcard.Token')
this._removeToken(proxy);
});
}
_updateToken(token) {
let objectPath = token.get_object_path();
delete this._insertedTokens[objectPath];
if (token.IsInserted)
this._insertedTokens[objectPath] = token;
if (token.UsedToLogin)
this._loginToken = token;
}
_addToken(token) {
this._updateToken(token);
token.connect('g-properties-changed', (proxy, properties) => {
const isInsertedChanged = !!properties.lookup_value('IsInserted', null);
if (isInsertedChanged) {
this._updateToken(token);
if (token.IsInserted)
this.emit('smartcard-inserted', token);
else
this.emit('smartcard-removed', token);
}
});
// Emit a smartcard-inserted at startup if it's already plugged in
if (token.IsInserted)
this.emit('smartcard-inserted', token);
}
_removeToken(token) {
let objectPath = token.get_object_path();
if (this._insertedTokens[objectPath] === token) {
delete this._insertedTokens[objectPath];
this.emit('smartcard-removed', token);
}
if (this._loginToken === token)
this._loginToken = null;
token.disconnectAll();
}
hasInsertedTokens() {
return Object.keys(this._insertedTokens).length > 0;
}
hasInsertedLoginToken() {
if (!this._loginToken)
return false;
if (!this._loginToken.IsInserted)
return false;
return true;
}
}

481
js/misc/systemActions.js Normal file
View file

@ -0,0 +1,481 @@
import AccountsService from 'gi://AccountsService';
import Clutter from 'gi://Clutter';
import Gdm from 'gi://Gdm';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import * as GnomeSession from './gnomeSession.js';
import * as LoginManager from './loginManager.js';
import * as Main from '../ui/main.js';
import * as Screenshot from '../ui/screenshot.js';
const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown';
const LOGIN_SCREEN_SCHEMA = 'org.gnome.login-screen';
const DISABLE_USER_SWITCH_KEY = 'disable-user-switching';
const DISABLE_LOCK_SCREEN_KEY = 'disable-lock-screen';
const DISABLE_LOG_OUT_KEY = 'disable-log-out';
const DISABLE_RESTART_KEY = 'disable-restart-buttons';
const ALWAYS_SHOW_LOG_OUT_KEY = 'always-show-log-out';
const POWER_OFF_ACTION_ID = 'power-off';
const RESTART_ACTION_ID = 'restart';
const LOCK_SCREEN_ACTION_ID = 'lock-screen';
const LOGOUT_ACTION_ID = 'logout';
const SUSPEND_ACTION_ID = 'suspend';
const SWITCH_USER_ACTION_ID = 'switch-user';
const LOCK_ORIENTATION_ACTION_ID = 'lock-orientation';
const SCREENSHOT_UI_ACTION_ID = 'open-screenshot-ui';
let _singleton = null;
/**
* @returns {SystemActions}
*/
export function getDefault() {
if (_singleton == null)
_singleton = new SystemActions();
return _singleton;
}
const SystemActions = GObject.registerClass({
Properties: {
'can-power-off': GObject.ParamSpec.boolean(
'can-power-off', null, null,
GObject.ParamFlags.READABLE,
false),
'can-restart': GObject.ParamSpec.boolean(
'can-restart', null, null,
GObject.ParamFlags.READABLE,
false),
'can-suspend': GObject.ParamSpec.boolean(
'can-suspend', null, null,
GObject.ParamFlags.READABLE,
false),
'can-lock-screen': GObject.ParamSpec.boolean(
'can-lock-screen', null, null,
GObject.ParamFlags.READABLE,
false),
'can-switch-user': GObject.ParamSpec.boolean(
'can-switch-user', null, null,
GObject.ParamFlags.READABLE,
false),
'can-logout': GObject.ParamSpec.boolean(
'can-logout', null, null,
GObject.ParamFlags.READABLE,
false),
'can-lock-orientation': GObject.ParamSpec.boolean(
'can-lock-orientation', null, null,
GObject.ParamFlags.READABLE,
false),
'orientation-lock-icon': GObject.ParamSpec.string(
'orientation-lock-icon', null, null,
GObject.ParamFlags.READWRITE,
null),
},
}, class SystemActions extends GObject.Object {
_init() {
super._init();
this._canHavePowerOff = true;
this._canHaveSuspend = true;
function tokenizeKeywords(keywords) {
return keywords.split(';').map(keyword => GLib.str_tokenize_and_fold(keyword, null)).flat(2);
}
this._actions = new Map();
this._actions.set(POWER_OFF_ACTION_ID, {
// Translators: The name of the power-off action in search
name: C_('search-result', 'Power Off'),
iconName: 'system-shutdown-symbolic',
// Translators: A list of keywords that match the power-off action, separated by semicolons
keywords: tokenizeKeywords(_('power off;poweroff;shutdown;halt;stop')),
available: false,
});
this._actions.set(RESTART_ACTION_ID, {
// Translators: The name of the restart action in search
name: C_('search-result', 'Restart'),
iconName: 'system-reboot-symbolic',
// Translators: A list of keywords that match the restart action, separated by semicolons
keywords: tokenizeKeywords(_('reboot;restart;')),
available: false,
});
this._actions.set(LOCK_SCREEN_ACTION_ID, {
// Translators: The name of the lock screen action in search
name: C_('search-result', 'Lock Screen'),
iconName: 'system-lock-screen-symbolic',
// Translators: A list of keywords that match the lock screen action, separated by semicolons
keywords: tokenizeKeywords(_('lock screen')),
available: false,
});
this._actions.set(LOGOUT_ACTION_ID, {
// Translators: The name of the logout action in search
name: C_('search-result', 'Log Out'),
iconName: 'system-log-out-symbolic',
// Translators: A list of keywords that match the logout action, separated by semicolons
keywords: tokenizeKeywords(_('logout;log out;sign off')),
available: false,
});
this._actions.set(SUSPEND_ACTION_ID, {
// Translators: The name of the suspend action in search
name: C_('search-result', 'Suspend'),
iconName: 'media-playback-pause-symbolic',
// Translators: A list of keywords that match the suspend action, separated by semicolons
keywords: tokenizeKeywords(_('suspend;sleep')),
available: false,
});
this._actions.set(SWITCH_USER_ACTION_ID, {
// Translators: The name of the switch user action in search
name: C_('search-result', 'Switch User'),
iconName: 'system-switch-user-symbolic',
// Translators: A list of keywords that match the switch user action, separated by semicolons
keywords: tokenizeKeywords(_('switch user')),
available: false,
});
this._actions.set(LOCK_ORIENTATION_ACTION_ID, {
name: '',
iconName: '',
// Translators: A list of keywords that match the lock orientation action, separated by semicolons
keywords: tokenizeKeywords(_('lock orientation;unlock orientation;screen;rotation')),
available: false,
});
this._actions.set(SCREENSHOT_UI_ACTION_ID, {
// Translators: The name of the screenshot UI action in search
name: C_('search-result', 'Take a Screenshot'),
iconName: 'record-screen-symbolic',
// Translators: A list of keywords that match the screenshot UI action, separated by semicolons
keywords: tokenizeKeywords(_('screenshot;screencast;snip;capture;record')),
available: true,
});
this._loginScreenSettings = new Gio.Settings({schema_id: LOGIN_SCREEN_SCHEMA});
this._lockdownSettings = new Gio.Settings({schema_id: LOCKDOWN_SCHEMA});
this._orientationSettings = new Gio.Settings({schema_id: 'org.gnome.settings-daemon.peripherals.touchscreen'});
this._session = new GnomeSession.SessionManager();
this._loginManager = LoginManager.getLoginManager();
this._monitorManager = global.backend.get_monitor_manager();
this._userManager = AccountsService.UserManager.get_default();
this._userManager.connect('notify::is-loaded',
() => this._updateMultiUser());
this._userManager.connect('notify::has-multiple-users',
() => this._updateMultiUser());
this._userManager.connect('user-added',
() => this._updateMultiUser());
this._userManager.connect('user-removed',
() => this._updateMultiUser());
this._lockdownSettings.connect(`changed::${DISABLE_USER_SWITCH_KEY}`,
() => this._updateSwitchUser());
this._lockdownSettings.connect(`changed::${DISABLE_LOG_OUT_KEY}`,
() => this._updateLogout());
global.settings.connect(`changed::${ALWAYS_SHOW_LOG_OUT_KEY}`,
() => this._updateLogout());
this._lockdownSettings.connect(`changed::${DISABLE_LOCK_SCREEN_KEY}`,
() => this._updateLockScreen());
this._lockdownSettings.connect(`changed::${DISABLE_LOG_OUT_KEY}`,
() => this._updateHaveShutdown());
this.forceUpdate();
this._orientationSettings.connect('changed::orientation-lock', () => {
this._updateOrientationLock();
this._updateOrientationLockStatus();
});
Main.layoutManager.connect('monitors-changed',
() => this._updateOrientationLock());
this._monitorManager.connect('notify::panel-orientation-managed',
() => this._updateOrientationLock());
this._updateOrientationLock();
this._updateOrientationLockStatus();
Main.sessionMode.connect('updated', () => this._sessionUpdated());
this._sessionUpdated();
}
get canPowerOff() {
return this._actions.get(POWER_OFF_ACTION_ID).available;
}
get canRestart() {
return this._actions.get(RESTART_ACTION_ID).available;
}
get canSuspend() {
return this._actions.get(SUSPEND_ACTION_ID).available;
}
get canLockScreen() {
return this._actions.get(LOCK_SCREEN_ACTION_ID).available;
}
get canSwitchUser() {
return this._actions.get(SWITCH_USER_ACTION_ID).available;
}
get canLogout() {
return this._actions.get(LOGOUT_ACTION_ID).available;
}
get canLockOrientation() {
return this._actions.get(LOCK_ORIENTATION_ACTION_ID).available;
}
get orientationLockIcon() {
return this._actions.get(LOCK_ORIENTATION_ACTION_ID).iconName;
}
_updateOrientationLock() {
const available = this._monitorManager.get_panel_orientation_managed();
this._actions.get(LOCK_ORIENTATION_ACTION_ID).available = available;
this.notify('can-lock-orientation');
}
_updateOrientationLockStatus() {
let locked = this._orientationSettings.get_boolean('orientation-lock');
let action = this._actions.get(LOCK_ORIENTATION_ACTION_ID);
// Translators: The name of the lock orientation action in search
// and in the system status menu
let name = locked
? C_('search-result', 'Unlock Screen Rotation')
: C_('search-result', 'Lock Screen Rotation');
let iconName = locked
? 'rotation-locked-symbolic'
: 'rotation-allowed-symbolic';
action.name = name;
action.iconName = iconName;
this.notify('orientation-lock-icon');
}
_sessionUpdated() {
this._updateLockScreen();
this._updatePowerOff();
this._updateSuspend();
this._updateMultiUser();
}
forceUpdate() {
// Whether those actions are available or not depends on both lockdown
// settings and Polkit policy - we don't get change notifications for the
// latter, so their value may be outdated; force an update now
this._updateHaveShutdown();
this._updateHaveSuspend();
}
getMatchingActions(terms) {
// terms is a list of strings
terms = terms.map(
term => GLib.str_tokenize_and_fold(term, null)[0]).flat(2);
// tokenizing may return an empty array
if (terms.length === 0)
return [];
let results = [];
for (let [key, {available, keywords}] of this._actions) {
if (available && terms.every(t => keywords.some(k => k.startsWith(t))))
results.push(key);
}
return results;
}
getName(id) {
return this._actions.get(id).name;
}
getIconName(id) {
return this._actions.get(id).iconName;
}
activateAction(id) {
switch (id) {
case POWER_OFF_ACTION_ID:
this.activatePowerOff();
break;
case RESTART_ACTION_ID:
this.activateRestart();
break;
case LOCK_SCREEN_ACTION_ID:
this.activateLockScreen();
break;
case LOGOUT_ACTION_ID:
this.activateLogout();
break;
case SUSPEND_ACTION_ID:
this.activateSuspend();
break;
case SWITCH_USER_ACTION_ID:
this.activateSwitchUser();
break;
case LOCK_ORIENTATION_ACTION_ID:
this.activateLockOrientation();
break;
case SCREENSHOT_UI_ACTION_ID:
this.activateScreenshotUI();
break;
}
}
_updateLockScreen() {
let showLock = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
let allowLockScreen = !this._lockdownSettings.get_boolean(DISABLE_LOCK_SCREEN_KEY);
this._actions.get(LOCK_SCREEN_ACTION_ID).available = showLock && allowLockScreen && LoginManager.canLock();
this.notify('can-lock-screen');
}
async _updateHaveShutdown() {
try {
const [canShutdown] = await this._session.CanShutdownAsync();
this._canHavePowerOff = canShutdown;
} catch {
this._canHavePowerOff = false;
}
this._updatePowerOff();
}
_updatePowerOff() {
let disabled = Main.sessionMode.isLocked ||
(Main.sessionMode.isGreeter &&
this._loginScreenSettings.get_boolean(DISABLE_RESTART_KEY));
this._actions.get(POWER_OFF_ACTION_ID).available = this._canHavePowerOff && !disabled;
this.notify('can-power-off');
this._actions.get(RESTART_ACTION_ID).available = this._canHavePowerOff && !disabled;
this.notify('can-restart');
}
async _updateHaveSuspend() {
const {canSuspend, needsAuth} = await this._loginManager.canSuspend();
this._canHaveSuspend = canSuspend;
this._suspendNeedsAuth = needsAuth;
this._updateSuspend();
}
_updateSuspend() {
let disabled = (Main.sessionMode.isLocked &&
this._suspendNeedsAuth) ||
(Main.sessionMode.isGreeter &&
this._loginScreenSettings.get_boolean(DISABLE_RESTART_KEY));
this._actions.get(SUSPEND_ACTION_ID).available = this._canHaveSuspend && !disabled;
this.notify('can-suspend');
}
_updateMultiUser() {
this._updateLogout();
this._updateSwitchUser();
}
_updateSwitchUser() {
let allowSwitch = !this._lockdownSettings.get_boolean(DISABLE_USER_SWITCH_KEY);
let multiUser = this._userManager.can_switch() && this._userManager.has_multiple_users;
let shouldShowInMode = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
let visible = allowSwitch && multiUser && shouldShowInMode;
this._actions.get(SWITCH_USER_ACTION_ID).available = visible;
this.notify('can-switch-user');
return visible;
}
_updateLogout() {
let user = this._userManager.get_user(GLib.get_user_name());
let allowLogout = !this._lockdownSettings.get_boolean(DISABLE_LOG_OUT_KEY);
let alwaysShow = global.settings.get_boolean(ALWAYS_SHOW_LOG_OUT_KEY);
let systemAccount = user.system_account;
let localAccount = user.local_account;
let multiUser = this._userManager.has_multiple_users;
let multiSession = Gdm.get_session_ids().length > 1;
let shouldShowInMode = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
let visible = allowLogout && (alwaysShow || multiUser || multiSession || systemAccount || !localAccount) && shouldShowInMode;
this._actions.get(LOGOUT_ACTION_ID).available = visible;
this.notify('can-logout');
return visible;
}
activateLockOrientation() {
if (!this._actions.get(LOCK_ORIENTATION_ACTION_ID).available)
throw new Error('The lock-orientation action is not available!');
let locked = this._orientationSettings.get_boolean('orientation-lock');
this._orientationSettings.set_boolean('orientation-lock', !locked);
}
activateLockScreen() {
if (!this._actions.get(LOCK_SCREEN_ACTION_ID).available)
throw new Error('The lock-screen action is not available!');
Main.screenShield.lock(true);
}
activateSwitchUser() {
if (!this._actions.get(SWITCH_USER_ACTION_ID).available)
throw new Error('The switch-user action is not available!');
if (Main.screenShield)
Main.screenShield.lock(false);
Clutter.threads_add_repaint_func(Clutter.RepaintFlags.POST_PAINT, () => {
Gdm.goto_login_session_sync(null);
return false;
});
}
activateLogout() {
if (!this._actions.get(LOGOUT_ACTION_ID).available)
throw new Error('The logout action is not available!');
Main.overview.hide();
this._session.LogoutAsync(0).catch(logError);
}
activatePowerOff() {
if (!this._actions.get(POWER_OFF_ACTION_ID).available)
throw new Error('The power-off action is not available!');
this._session.ShutdownAsync(0).catch(logError);
}
activateRestart() {
if (!this._actions.get(RESTART_ACTION_ID).available)
throw new Error('The restart action is not available!');
this._session.RebootAsync().catch(logError);
}
activateSuspend() {
if (!this._actions.get(SUSPEND_ACTION_ID).available)
throw new Error('The suspend action is not available!');
this._loginManager.suspend();
}
activateScreenshotUI() {
if (!this._actions.get(SCREENSHOT_UI_ACTION_ID).available)
throw new Error('The screenshot UI action is not available!');
if (this._overviewHiddenId)
return;
this._overviewHiddenId = Main.overview.connect('hidden', () => {
Main.overview.disconnect(this._overviewHiddenId);
delete this._overviewHiddenId;
Screenshot.showScreenshotUI();
});
}
});

1169
js/misc/timeLimitsManager.js Normal file

File diff suppressed because it is too large Load diff

470
js/misc/util.js Normal file
View file

@ -0,0 +1,470 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';
import GnomeDesktop from 'gi://GnomeDesktop';
import {formatTime} from './dateUtils.js';
// http://daringfireball.net/2010/07/improved_regex_for_matching_urls
const _balancedParens = '\\([^\\s()<>]+\\)';
const _leadingJunk = '[\\s`(\\[{\'\\"<\u00AB\u201C\u2018]';
const _notTrailingJunk = '[^\\s`!()\\[\\]{};:\'\\".,<>?\u00AB\u00BB\u200E\u200F\u201C\u201D\u2018\u2019\u202A\u202C]';
const _urlRegexp = new RegExp(
`(^|${_leadingJunk})` +
'(' +
'(?:' +
'(?:http|https|ftp)://' + // scheme://
'|' +
'www\\d{0,3}[.]' + // www.
'|' +
'[a-z0-9.\\-]+[.][a-z]{2,4}/' + // foo.xx/
')' +
'(?:' + // one or more:
'[^\\s()<>]+' + // run of non-space non-()
'|' + // or
`${_balancedParens}` + // balanced parens
')+' +
'(?:' + // end with:
`${_balancedParens}` + // balanced parens
'|' + // or
`${_notTrailingJunk}` + // last non-junk char
')' +
')', 'gi');
let _desktopSettings = null;
/**
* findUrls:
*
* @param {string} str string to find URLs in
*
* Searches `str` for URLs and returns an array of objects with %url
* properties showing the matched URL string, and %pos properties indicating
* the position within `str` where the URL was found.
*
* @returns {{url: string, pos: number}[]} the list of match objects, as described above
*/
export function findUrls(str) {
let res = [], match;
while ((match = _urlRegexp.exec(str)))
res.push({url: match[2], pos: match.index + match[1].length});
return res;
}
/**
* spawn:
*
* Runs `argv` in the background, handling any errors that occur
* when trying to start the program.
*
* @param {readonly string[]} argv an argv array
*/
export function spawn(argv) {
try {
trySpawn(argv);
} catch (err) {
_handleSpawnError(argv[0], err);
}
}
/**
* spawnCommandLine:
*
* @param {readonly string[]} commandLine a command line
*
* Runs commandLine in the background, handling any errors that
* occur when trying to parse or start the program.
*/
export function spawnCommandLine(commandLine) {
try {
let [success_, argv] = GLib.shell_parse_argv(commandLine);
trySpawn(argv);
} catch (err) {
_handleSpawnError(commandLine, err);
}
}
/**
* spawnApp:
*
* @param {readonly string[]} argv an argv array
*
* Runs argv as if it was an application, handling startup notification
*/
export function spawnApp(argv) {
try {
const app = Gio.AppInfo.create_from_commandline(argv.join(' '),
null,
Gio.AppInfoCreateFlags.SUPPORTS_STARTUP_NOTIFICATION);
let context = global.create_app_launch_context(0, -1);
app.launch([], context);
} catch (err) {
_handleSpawnError(argv[0], err);
}
}
/**
* trySpawn:
*
* @param {readonly string[]} argv an argv array
*
* Runs argv in the background. If launching argv fails,
* this will throw an error.
*/
export function trySpawn(argv) {
let pid;
try {
const launchContext = global.create_app_launch_context(0, -1);
pid = Shell.util_spawn_async(
null, argv, launchContext.get_environment(),
GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD);
} catch (err) {
/* Rewrite the error in case of ENOENT */
if (err.matches(GLib.SpawnError, GLib.SpawnError.NOENT)) {
throw new GLib.SpawnError({
code: GLib.SpawnError.NOENT,
message: _('Command not found'),
});
} else if (err instanceof GLib.Error) {
// The exception from gjs contains an error string like:
// Error invoking GLib.spawn_command_line_async: Failed to
// execute child process "foo" (No such file or directory)
// We are only interested in the part in the parentheses. (And
// we can't pattern match the text, since it gets localized.)
let message = err.message.replace(/.*\((.+)\)/, '$1');
throw new err.constructor({code: err.code, message});
} else {
throw err;
}
}
// Async call, we don't need the reply though
GnomeDesktop.start_systemd_scope(argv[0], pid, null, null, null, () => {});
// Dummy child watch; we don't want to double-fork internally
// because then we lose the parent-child relationship, which
// can break polkit. See https://bugzilla.redhat.com//show_bug.cgi?id=819275
GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, () => {});
}
/**
* trySpawnCommandLine:
*
* @param {readonly string[]} commandLine a command line
*
* Runs commandLine in the background. If launching commandLine
* fails, this will throw an error.
*/
export function trySpawnCommandLine(commandLine) {
const [, argv] = GLib.shell_parse_argv(commandLine);
trySpawn(argv);
}
function _handleSpawnError(command, err) {
const title = _('Execution of “%s” failed:').format(command);
// Use dynamic import to not pull in UI related code in unit tests
import('../ui/main.js').then(
({notifyError}) => notifyError(title, err.message));
}
/**
* Fix up embedded markup so that it can be displayed correctly in
* UI elements such as the message list. In some cases, we might want to
* keep some of the embedded markup, so specify allowMarkup for that case
*
* @param {string} text containing markup to escape and parse
* @param {boolean} allowMarkup to allow embedded markup or just escape it all
* @returns the escaped string
*/
export function fixMarkup(text, allowMarkup) {
if (allowMarkup) {
// Support &amp;, &quot;, &apos;, &lt; and &gt;, escape all other
// occurrences of '&'.
let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&amp;');
// Support <b>, <i>, and <u>, escape anything else
// so it displays as raw markup.
_text = _text.replace(/<(?!\/?[biu]>)/g, '&lt;');
try {
Pango.parse_markup(_text, -1, '');
return _text;
} catch {}
}
// !allowMarkup, or invalid markup
return GLib.markup_escape_text(text, -1);
}
/**
* Returns an {@link St.Label} with the date passed formatted
* using {@link formatTime}
*
* @param {Date} date the date to format for the label
* @param {object} params params for {@link formatTime}
* @returns {St.Label}
*/
export function createTimeLabel(date, params) {
if (_desktopSettings == null)
_desktopSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'});
let label = new St.Label({text: formatTime(date, params)});
_desktopSettings.connectObject(
'changed::clock-format', () => (label.text = formatTime(date, params)),
label);
return label;
}
/**
* lowerBound:
*
* @template T, [K=T]
* @param {readonly T[]} array an array or array-like object, already sorted
* according to `cmp`
* @param {K} val the value to add
* @param {(a: T, val: K) => number} cmp a comparator (or undefined to compare as numbers)
* @returns {number}
*
* Returns the position of the first element that is not
* lower than `val`, according to `cmp`.
* That is, returns the first position at which it
* is possible to insert val without violating the
* order.
*
* This is quite like an ordinary binary search, except
* that it doesn't stop at first element comparing equal.
*/
function lowerBound(array, val, cmp) {
let min, max, mid, v;
cmp ||= (a, b) => a - b;
if (array.length === 0)
return 0;
min = 0;
max = array.length;
while (min < (max - 1)) {
mid = Math.floor((min + max) / 2);
v = cmp(array[mid], val);
if (v < 0)
min = mid + 1;
else
max = mid;
}
return min === max || cmp(array[min], val) < 0 ? max : min;
}
/**
* insertSorted:
*
* @template T, [K=T]
* @param {T[]} array an array sorted according to `cmp`
* @param {K} val a value to insert
* @param {(a: T, val: K) => number} cmp the sorting function
* @returns {number}
*
* Inserts `val` into `array`, preserving the
* sorting invariants.
*
* Returns the position at which it was inserted
*/
export function insertSorted(array, val, cmp) {
let pos = lowerBound(array, val, cmp);
array.splice(pos, 0, val);
return pos;
}
/**
* @param {number} start
* @param {number} end
* @param {number} progress
* @returns {number}
*/
export function lerp(start, end, progress) {
return start + progress * (end - start);
}
/**
* _GNOMEversionToNumber:
*
* @param {string} version a GNOME version element
* @returns {number}
*
* Like Number() but returns sortable values for special-cases
* 'alpha' and 'beta'. Returns NaN for unhandled 'versions'.
*/
function _GNOMEversionToNumber(version) {
let ret = Number(version);
if (!isNaN(ret))
return ret;
if (version === 'alpha')
return -3;
if (version === 'beta')
return -2;
if (version === 'rc')
return -1;
return ret;
}
/**
* GNOMEversionCompare:
*
* @param {string} version1 a string containing a GNOME version
* @param {string} version2 a string containing another GNOME version
* @returns {number}
*
* Returns an integer less than, equal to, or greater than
* zero, if `version1` is older, equal or newer than `version2`
*/
export function GNOMEversionCompare(version1, version2) {
const v1Array = version1.split('.');
const v2Array = version2.split('.');
for (let i = 0; i < Math.max(v1Array.length, v2Array.length); i++) {
let elemV1 = _GNOMEversionToNumber(v1Array[i] || '0');
let elemV2 = _GNOMEversionToNumber(v2Array[i] || '0');
if (elemV1 < elemV2)
return -1;
if (elemV1 > elemV2)
return 1;
}
return 0;
}
export class DBusSenderChecker {
/**
* @param {string[]} allowList - list of allowed well-known names
*/
constructor(allowList) {
this._allowlistMap = new Map();
this._uninitializedNames = new Set(allowList);
this._initializedPromise = new Promise(resolve => {
this._resolveInitialized = resolve;
});
this._watchList = allowList.map(name => {
return Gio.DBus.watch_name(Gio.BusType.SESSION,
name,
Gio.BusNameWatcherFlags.NONE,
(conn_, name_, owner) => {
this._allowlistMap.set(name, owner);
this._checkAndResolveInitialized(name);
},
() => {
this._allowlistMap.delete(name);
this._checkAndResolveInitialized(name);
});
});
}
/**
* @param {string} name - bus name for which the watcher got initialized
*/
_checkAndResolveInitialized(name) {
if (this._uninitializedNames.delete(name) &&
this._uninitializedNames.size === 0)
this._resolveInitialized();
}
/**
* @async
* @param {string} sender - the bus name that invoked the checked method
* @returns {bool}
*/
async _isSenderAllowed(sender) {
await this._initializedPromise;
return [...this._allowlistMap.values()].includes(sender);
}
/**
* Check whether the bus name that invoked @invocation maps
* to an entry in the allow list.
*
* @async
* @throws
* @param {Gio.DBusMethodInvocation} invocation - the invocation
* @returns {void}
*/
async checkInvocation(invocation) {
if (global.context.unsafe_mode)
return;
if (await this._isSenderAllowed(invocation.get_sender()))
return;
throw new GLib.Error(Gio.DBusError,
Gio.DBusError.ACCESS_DENIED,
`${invocation.get_method_name()} is not allowed`);
}
/**
* @returns {void}
*/
destroy() {
for (const id in this._watchList)
Gio.DBus.unwatch_name(id);
this._watchList = [];
}
}
/* @class Highlighter Highlight given terms in text using markup. */
export class Highlighter {
/**
* @param {?string[]} terms - list of terms to highlight
*/
constructor(terms) {
if (!terms)
return;
const escapedTerms = terms
.map(term => Shell.util_regex_escape(term))
.filter(term => term.length > 0);
if (escapedTerms.length === 0)
return;
this._highlightRegex = new RegExp(
`(${escapedTerms.join('|')})`, 'gi');
}
/**
* Highlight all occurences of the terms defined for this
* highlighter in the provided text using markup.
*
* @param {string} text - text to highlight the defined terms in
* @returns {string}
*/
highlight(text) {
if (!this._highlightRegex)
return GLib.markup_escape_text(text, -1);
let escaped = [];
let lastMatchEnd = 0;
let match;
while ((match = this._highlightRegex.exec(text))) {
if (match.index > lastMatchEnd) {
let unmatched = GLib.markup_escape_text(
text.slice(lastMatchEnd, match.index), -1);
escaped.push(unmatched);
}
let matched = GLib.markup_escape_text(match[0], -1);
escaped.push(`<b>${matched}</b>`);
lastMatchEnd = match.index + match[0].length;
}
let unmatched = GLib.markup_escape_text(
text.slice(lastMatchEnd), -1);
escaped.push(unmatched);
return escaped.join('');
}
}

327
js/misc/weather.js Normal file
View file

@ -0,0 +1,327 @@
import Geoclue from 'gi://Geoclue';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GWeather from 'gi://GWeather';
import Shell from 'gi://Shell';
import * as Signals from './signals.js';
import * as PermissionStore from './permissionStore.js';
import {loadInterfaceXML} from './fileUtils.js';
Gio._promisify(Geoclue.Simple, 'new');
const WeatherIntegrationIface = loadInterfaceXML('org.gnome.Shell.WeatherIntegration');
const WEATHER_BUS_NAME = 'org.gnome.Weather';
const WEATHER_OBJECT_PATH = '/org/gnome/Weather';
const WEATHER_INTEGRATION_IFACE = 'org.gnome.Shell.WeatherIntegration';
const WEATHER_APP_ID = 'org.gnome.Weather.desktop';
// Minimum time between updates to show loading indication
const UPDATE_THRESHOLD = 10 * GLib.TIME_SPAN_MINUTE;
export class WeatherClient extends Signals.EventEmitter {
constructor() {
super();
this._loading = false;
this._locationValid = false;
this._lastUpdate = GLib.DateTime.new_from_unix_local(0);
this._autoLocationRequested = false;
this._mostRecentLocation = null;
this._gclueService = null;
this._gclueStarted = false;
this._gclueStarting = false;
this._gclueLocationChangedId = 0;
this._needsAuth = true;
this._weatherAuthorized = false;
this._permStore = new PermissionStore.PermissionStore(async (proxy, error) => {
if (error) {
log(`Failed to connect to permissionStore: ${error.message}`);
return;
}
if (this._permStore.g_name_owner == null) {
// Failed to auto-start, likely because xdg-desktop-portal
// isn't installed; don't restrict access to location service
this._weatherAuthorized = true;
this._updateAutoLocation();
return;
}
let [perms, data] = [{}, null];
try {
[perms, data] = await this._permStore.LookupAsync('gnome', 'geolocation');
} catch (err) {
log(`Error looking up permission: ${err.message}`);
}
const params = ['gnome', 'geolocation', false, data, perms];
this._onPermStoreChanged(this._permStore, '', params);
});
this._permStore.connectSignal('Changed',
this._onPermStoreChanged.bind(this));
this._locationSettings = new Gio.Settings({schema_id: 'org.gnome.system.location'});
this._locationSettings.connect('changed::enabled',
this._updateAutoLocation.bind(this));
this._world = GWeather.Location.get_world();
const providers =
GWeather.Provider.METAR |
GWeather.Provider.MET_NO |
GWeather.Provider.OWM;
this._weatherInfo = new GWeather.Info({
application_id: 'org.gnome.Shell',
contact_info: 'https://gitlab.gnome.org/GNOME/gnome-shell/-/raw/HEAD/gnome-shell.doap',
enabled_providers: providers,
});
this._weatherInfo.connect_after('updated', () => {
this._lastUpdate = GLib.DateTime.new_now_local();
this.emit('changed');
});
this._weatherApp = null;
this._weatherProxy = null;
this._createWeatherProxy();
this._settings = new Gio.Settings({
schema_id: 'org.gnome.shell.weather',
});
this._settings.connect('changed::automatic-location',
this._onAutomaticLocationChanged.bind(this));
this._onAutomaticLocationChanged();
this._settings.connect('changed::locations',
this._onLocationsChanged.bind(this));
this._onLocationsChanged();
this._appSystem = Shell.AppSystem.get_default();
this._appSystem.connect('installed-changed',
this._onInstalledChanged.bind(this));
this._onInstalledChanged();
}
get available() {
return this._weatherApp != null;
}
get loading() {
return this._loading;
}
get hasLocation() {
return this._locationValid;
}
get info() {
return this._weatherInfo;
}
activateApp() {
if (this._weatherApp)
this._weatherApp.activate();
}
update() {
if (!this._locationValid)
return;
let now = GLib.DateTime.new_now_local();
// Update without loading indication if the current info is recent enough
if (this._weatherInfo.is_valid() &&
now.difference(this._lastUpdate) < UPDATE_THRESHOLD)
this._weatherInfo.update();
else
this._loadInfo();
}
get _useAutoLocation() {
return this._autoLocationRequested &&
this._locationSettings.get_boolean('enabled') &&
(!this._needsAuth || this._weatherAuthorized);
}
async _createWeatherProxy() {
const nodeInfo = Gio.DBusNodeInfo.new_for_xml(WeatherIntegrationIface);
try {
this._weatherProxy = await Gio.DBusProxy.new(
Gio.DBus.session,
Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES,
nodeInfo.lookup_interface(WEATHER_INTEGRATION_IFACE),
WEATHER_BUS_NAME,
WEATHER_OBJECT_PATH,
WEATHER_INTEGRATION_IFACE,
null);
} catch (e) {
log(`Failed to create GNOME Weather proxy: ${e}`);
return;
}
this._weatherProxy.connect('g-properties-changed',
this._onWeatherPropertiesChanged.bind(this));
this._onWeatherPropertiesChanged();
}
_onWeatherPropertiesChanged() {
if (this._weatherProxy.g_name_owner == null)
return;
this._settings.set_boolean('automatic-location',
this._weatherProxy.AutomaticLocation);
this._settings.set_value('locations',
new GLib.Variant('av', this._weatherProxy.Locations));
}
_onInstalledChanged() {
let hadApp = this._weatherApp != null;
this._weatherApp = this._appSystem.lookup_app(WEATHER_APP_ID);
let haveApp = this._weatherApp != null;
if (hadApp !== haveApp)
this.emit('changed');
let neededAuth = this._needsAuth;
this._needsAuth = this._weatherApp === null ||
this._weatherApp.app_info.has_key('X-Flatpak');
if (neededAuth !== this._needsAuth)
this._updateAutoLocation();
}
_loadInfo() {
let id = this._weatherInfo.connect('updated', () => {
this._weatherInfo.disconnect(id);
this._loading = false;
});
this._loading = true;
this.emit('changed');
this._weatherInfo.update();
}
_locationsEqual(loc1, loc2) {
if (loc1 === loc2)
return true;
if (loc1 == null || loc2 == null)
return false;
return loc1.equal(loc2);
}
_setLocation(location) {
if (this._locationsEqual(this._weatherInfo.location, location))
return;
this._weatherInfo.abort();
this._weatherInfo.set_location(location);
this._locationValid = location != null;
if (location)
this._loadInfo();
else
this.emit('changed');
}
_updateLocationMonitoring() {
if (this._useAutoLocation) {
if (this._gclueLocationChangedId !== 0 || this._gclueService == null)
return;
this._gclueLocationChangedId =
this._gclueService.connect('notify::location',
this._onGClueLocationChanged.bind(this));
this._onGClueLocationChanged();
} else {
if (this._gclueLocationChangedId)
this._gclueService.disconnect(this._gclueLocationChangedId);
this._gclueLocationChangedId = 0;
}
}
async _startGClueService() {
if (this._gclueStarting)
return;
this._gclueStarting = true;
try {
this._gclueService = await Geoclue.Simple.new(
'org.gnome.Shell', Geoclue.AccuracyLevel.CITY, null);
} catch (e) {
log(`Failed to connect to Geoclue2 service: ${e.message}`);
this._setLocation(this._mostRecentLocation);
return;
}
this._gclueStarted = true;
this._gclueService.get_client().distance_threshold = 100;
this._updateLocationMonitoring();
}
_onGClueLocationChanged() {
let geoLocation = this._gclueService.location;
// Provide empty name so GWeather sets location name
const location = GWeather.Location.new_detached('',
null,
geoLocation.latitude,
geoLocation.longitude);
this._setLocation(location);
}
_onAutomaticLocationChanged() {
let useAutoLocation = this._settings.get_boolean('automatic-location');
if (this._autoLocationRequested === useAutoLocation)
return;
this._autoLocationRequested = useAutoLocation;
this._updateAutoLocation();
}
_updateAutoLocation() {
this._updateLocationMonitoring();
if (this._useAutoLocation)
this._startGClueService();
else
this._setLocation(this._mostRecentLocation);
}
_onLocationsChanged() {
let locations = this._settings.get_value('locations').deepUnpack();
let serialized = locations.shift();
let mostRecentLocation = null;
if (serialized)
mostRecentLocation = this._world.deserialize(serialized);
if (this._locationsEqual(this._mostRecentLocation, mostRecentLocation))
return;
this._mostRecentLocation = mostRecentLocation;
if (!this._useAutoLocation || !this._gclueStarted)
this._setLocation(this._mostRecentLocation);
}
_onPermStoreChanged(proxy, sender, params) {
let [table, id, deleted_, data_, perms] = params;
if (table !== 'gnome' || id !== 'geolocation')
return;
let permission = perms['org.gnome.Weather'] || ['NONE'];
let [accuracy] = permission;
this._weatherAuthorized = accuracy !== 'NONE';
this._updateAutoLocation();
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/shell">
<file>portalHelper/main.js</file>
<file>misc/config.js</file>
<file>misc/dbusUtils.js</file>
<file>misc/fileUtils.js</file>
</gresource>
</gresources>

370
js/portalHelper/main.js Normal file
View file

@ -0,0 +1,370 @@
import Adw from 'gi://Adw?version=1';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk?version=4.0';
import WebKit from 'gi://WebKit?version=6.0';
import * as Gettext from 'gettext';
import {programInvocationName, programArgs} from 'system';
const _ = Gettext.gettext;
import * as Config from '../misc/config.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const PortalHelperResult = {
CANCELLED: 0,
COMPLETED: 1,
RECHECK: 2,
};
const PortalHelperSecurityLevel = {
NOT_YET_DETERMINED: 0,
SECURE: 1,
INSECURE: 2,
};
const HTTP_URI_FLAGS =
GLib.UriFlags.HAS_PASSWORD |
GLib.UriFlags.ENCODED_PATH |
GLib.UriFlags.ENCODED_QUERY |
GLib.UriFlags.ENCODED_FRAGMENT |
GLib.UriFlags.SCHEME_NORMALIZE |
GLib.UriFlags.PARSE_RELAXED;
const CONNECTIVITY_CHECK_HOST = 'nmcheck.gnome.org';
const CONNECTIVITY_CHECK_URI = `http://${CONNECTIVITY_CHECK_HOST}`;
const CONNECTIVITY_RECHECK_RATELIMIT_TIMEOUT = 30 * GLib.USEC_PER_SEC;
const HelperDBusInterface = loadInterfaceXML('org.gnome.Shell.PortalHelper');
const PortalSecurityButton = GObject.registerClass(
class PortalSecurityButton extends Gtk.MenuButton {
_init() {
const popover = new Gtk.Popover();
super._init({
popover,
visible: false,
});
const vbox = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
margin_top: 6,
margin_bottom: 6,
margin_start: 6,
margin_end: 6,
spacing: 6,
});
popover.set_child(vbox);
const hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
halign: Gtk.Align.CENTER,
});
vbox.append(hbox);
this._secureIcon = new Gtk.Image();
hbox.append(this._secureIcon);
this._secureIcon.bind_property('icon-name',
this, 'icon-name',
GObject.BindingFlags.DEFAULT);
this._titleLabel = new Gtk.Label();
this._titleLabel.add_css_class('title');
hbox.append(this._titleLabel);
this._descriptionLabel = new Gtk.Label({
wrap: true,
max_width_chars: 32,
});
vbox.append(this._descriptionLabel);
}
setPopoverTitle(label) {
this._titleLabel.set_text(label);
}
setSecurityIcon(securityLevel) {
switch (securityLevel) {
case PortalHelperSecurityLevel.NOT_YET_DETERMINED:
this.hide();
break;
case PortalHelperSecurityLevel.SECURE:
this.show();
this._secureIcon.icon_name = 'channel-secure-symbolic';
this._descriptionLabel.label = _('Your connection seems to be secure');
break;
case PortalHelperSecurityLevel.INSECURE:
this.show();
this._secureIcon.icon_name = 'channel-insecure-symbolic';
this._descriptionLabel.label =
_('Your connection to this hotspot login is not secure. Passwords or other information you enter on this page can be viewed by people nearby.');
break;
}
}
});
const PortalWindow = GObject.registerClass(
class PortalWindow extends Gtk.ApplicationWindow {
_init(application, url, timestamp, doneCallback) {
super._init({
application,
title: _('Hotspot Login'),
default_width: 600,
default_height: 450,
});
const headerbar = new Gtk.HeaderBar();
this._secureMenu = new PortalSecurityButton();
headerbar.pack_start(this._secureMenu);
this.set_titlebar(headerbar);
if (!url) {
url = CONNECTIVITY_CHECK_URI;
this._originalUrlWasGnome = true;
} else {
this._originalUrlWasGnome = false;
}
this._uri = GLib.Uri.parse(url, HTTP_URI_FLAGS);
this._everSeenRedirect = false;
this._originalUrl = url;
this._doneCallback = doneCallback;
this._lastRecheck = 0;
this._recheckAtExit = false;
this._networkSession = WebKit.NetworkSession.new_ephemeral();
this._networkSession.set_proxy_settings(WebKit.NetworkProxyMode.NO_PROXY, null);
this._webContext = new WebKit.WebContext();
this._webContext.set_cache_model(WebKit.CacheModel.DOCUMENT_VIEWER);
this._webView = new WebKit.WebView({
networkSession: this._networkSession,
webContext: this._webContext,
});
this._webView.connect('decide-policy', this._onDecidePolicy.bind(this));
this._webView.connect('load-changed', this._onLoadChanged.bind(this));
this._webView.connect('insecure-content-detected', this._onInsecureContentDetected.bind(this));
this._webView.connect('load-failed-with-tls-errors', this._onLoadFailedWithTlsErrors.bind(this));
this._webView.load_uri(url);
this._webView.connect('notify::uri', this._syncUri.bind(this));
this._syncUri();
this.set_child(this._webView);
this.maximize();
this.present_with_time(timestamp);
this.application.set_accels_for_action('app.quit', ['<Primary>q', '<Primary>w']);
}
_syncUri() {
const {uri} = this._webView;
try {
const [, , host] = GLib.Uri.split_network(uri, HTTP_URI_FLAGS);
this._secureMenu.setPopoverTitle(host);
} catch (e) {
if (uri != null)
console.error(`Failed to parse Uri ${uri}: ${e.message}`);
this._secureMenu.setPopoverTitle('');
}
}
refresh() {
this._everSeenRedirect = false;
this._webView.load_uri(this._originalUrl);
}
vfunc_close_request() {
if (this._recheckAtExit)
this._doneCallback(PortalHelperResult.RECHECK);
else
this._doneCallback(PortalHelperResult.CANCELLED);
return false;
}
_onLoadChanged(view, loadEvent) {
if (loadEvent === WebKit.LoadEvent.STARTED) {
this._secureMenu.setSecurityIcon(PortalHelperSecurityLevel.NOT_YET_DETERMINED);
} else if (loadEvent === WebKit.LoadEvent.COMMITTED) {
let tlsInfo = this._webView.get_tls_info();
let ret = tlsInfo[0];
let flags = tlsInfo[2];
if (ret && flags === 0)
this._secureMenu.setSecurityIcon(PortalHelperSecurityLevel.SECURE);
else
this._secureMenu.setSecurityIcon(PortalHelperSecurityLevel.INSECURE);
}
}
_onInsecureContentDetected() {
this._secureMenu.setSecurityIcon(PortalHelperSecurityLevel.INSECURE);
}
_onLoadFailedWithTlsErrors(view, failingURI, certificate, _errors) {
this._secureMenu.setSecurityIcon(PortalHelperSecurityLevel.INSECURE);
let uri = GLib.Uri.parse(failingURI, HTTP_URI_FLAGS);
this._networkSession.allow_tls_certificate_for_host(certificate, uri.get_host());
this._webView.load_uri(failingURI);
return true;
}
_onDecidePolicy(view, decision, type) {
if (type === WebKit.PolicyDecisionType.RESPONSE)
return false;
const navigationAction = decision.get_navigation_action();
const request = navigationAction.get_request();
if (type === WebKit.PolicyDecisionType.NEW_WINDOW_ACTION) {
if (navigationAction.is_user_gesture()) {
// Even though the portal asks for a new window,
// perform the navigation in the current one. Some
// portals open a window as their last login step and
// ignoring that window causes them to not let the
// user go through. We don't risk popups taking over
// the page because we check that the navigation is
// user initiated.
this._webView.load_request(request);
}
decision.ignore();
return true;
}
const uri = GLib.Uri.parse(request.get_uri(), HTTP_URI_FLAGS);
if (uri.get_host() !== this._uri.get_host() && this._originalUrlWasGnome) {
if (uri.get_host() === CONNECTIVITY_CHECK_HOST && this._everSeenRedirect) {
// Yay, we got to gnome!
decision.ignore();
this._doneCallback(PortalHelperResult.COMPLETED);
return true;
} else if (uri.get_host() !== CONNECTIVITY_CHECK_HOST) {
this._everSeenRedirect = true;
}
}
// We *may* have finished here, but we don't know for
// sure. Tell gnome-shell to run another connectivity check
// (but ratelimit the checks, we don't want to spam
// nmcheck.gnome.org for portals that have 10 or more internal
// redirects - and unfortunately they exist)
// If we hit the rate limit, we also queue a recheck
// when the window is closed, just in case we miss the
// final check and don't realize we're connected
// This should not be a problem in the cancelled logic,
// because if the user doesn't want to start the login,
// we should not see any redirect at all, outside this._uri
let now = GLib.get_monotonic_time();
let shouldRecheck = (now - this._lastRecheck) >
CONNECTIVITY_RECHECK_RATELIMIT_TIMEOUT;
if (shouldRecheck) {
this._lastRecheck = now;
this._recheckAtExit = false;
this._doneCallback(PortalHelperResult.RECHECK);
} else {
this._recheckAtExit = true;
}
// Update the URI, in case of chained redirects, so we still
// think we're doing the login until gnome-shell kills us
this._uri = uri;
decision.use();
return true;
}
});
const WebPortalHelper = GObject.registerClass(
class WebPortalHelper extends Adw.Application {
_init() {
super._init({
application_id: 'org.gnome.Shell.PortalHelper',
flags: Gio.ApplicationFlags.IS_SERVICE,
inactivity_timeout: 30000,
});
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(HelperDBusInterface, this);
this._queue = [];
let action = new Gio.SimpleAction({name: 'quit'});
action.connect('activate', () => this.active_window.destroy());
this.add_action(action);
}
vfunc_dbus_register(connection, path) {
this._dbusImpl.export(connection, path);
super.vfunc_dbus_register(connection, path);
return true;
}
vfunc_dbus_unregister(connection, path) {
this._dbusImpl.unexport_from_connection(connection);
super.vfunc_dbus_unregister(connection, path);
}
vfunc_activate() {
// If launched manually (for example for testing), force a dummy authentication
// session with the default url
this.Authenticate('/org/gnome/dummy', '', 0);
}
Authenticate(connection, url, timestamp) {
this._queue.push({connection, url, timestamp});
this._processQueue();
}
Close(connection) {
for (let i = 0; i < this._queue.length; i++) {
let obj = this._queue[i];
if (obj.connection === connection) {
if (obj.window)
obj.window.destroy();
this._queue.splice(i, 1);
break;
}
}
this._processQueue();
}
Refresh(connection) {
for (let i = 0; i < this._queue.length; i++) {
let obj = this._queue[i];
if (obj.connection === connection) {
if (obj.window)
obj.window.refresh();
break;
}
}
}
_processQueue() {
if (this._queue.length === 0)
return;
let top = this._queue[0];
if (top.window != null)
return;
top.window = new PortalWindow(this, top.url, top.timestamp, result => {
this._dbusImpl.emit_signal('Done', new GLib.Variant('(ou)', [top.connection, result]));
});
}
});
Gettext.bindtextdomain(Config.GETTEXT_PACKAGE, Config.LOCALEDIR);
Gettext.textdomain(Config.GETTEXT_PACKAGE);
const app = new WebPortalHelper();
await app.runAsync([programInvocationName, ...programArgs]);

183
js/ui/accessDialog.js Normal file
View file

@ -0,0 +1,183 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as CheckBox from './checkBox.js';
import * as Dialog from './dialog.js';
import * as ModalDialog from './modalDialog.js';
import {DBusSenderChecker} from '../misc/util.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const RequestIface = loadInterfaceXML('org.freedesktop.impl.portal.Request');
const AccessIface = loadInterfaceXML('org.freedesktop.impl.portal.Access');
/** @enum {number} */
const DialogResponse = {
OK: 0,
CANCEL: 1,
CLOSED: 2,
};
const ALLOWED_SENDERS = [
'org.gnome.RemoteDesktop.Handover',
'org.freedesktop.impl.portal.desktop.gnome',
];
const AccessDialog = GObject.registerClass(
class AccessDialog extends ModalDialog.ModalDialog {
_init(invocation, handle, title, description, body, options) {
super._init({styleClass: 'access-dialog'});
this._invocation = invocation;
this._handle = handle;
this._requestExported = false;
this._request = Gio.DBusExportedObject.wrapJSObject(RequestIface, this);
for (let option in options)
options[option] = options[option].deepUnpack();
this._buildLayout(title, description, body, options);
}
_buildLayout(title, description, body, options) {
// No support for non-modal system dialogs, so ignore the option
// let modal = options['modal'] || true;
let denyLabel = options['deny_label'] || _('Deny');
let grantLabel = options['grant_label'] || _('Allow');
let choices = options['choices'] || [];
let content = new Dialog.MessageDialogContent({title, description});
this.contentLayout.add_child(content);
this._choices = new Map();
for (let i = 0; i < choices.length; i++) {
let [id, name, opts, selected] = choices[i];
if (opts.length > 0)
continue; // radio buttons, not implemented
let check = new CheckBox.CheckBox();
check.getLabelActor().text = name;
check.checked = selected === 'true';
content.add_child(check);
this._choices.set(id, check);
}
if (body) {
let bodyLabel = new St.Label({
text: body,
x_align: Clutter.ActorAlign.CENTER,
});
bodyLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
bodyLabel.clutter_text.line_wrap = true;
content.add_child(bodyLabel);
}
this.addButton({
label: denyLabel,
action: () => this._sendResponse(DialogResponse.CANCEL),
key: Clutter.KEY_Escape,
});
this.addButton({
label: grantLabel,
action: () => this._sendResponse(DialogResponse.OK),
});
}
open() {
if (!super.open())
return false;
let connection = this._invocation.get_connection();
this._requestExported = this._request.export(connection, this._handle);
return true;
}
CloseAsync(invocation, _params) {
if (this._invocation.get_sender() !== invocation.get_sender()) {
invocation.return_error_literal(
Gio.DBusError,
Gio.DBusError.ACCESS_DENIED,
'');
return;
}
this._sendResponse(DialogResponse.CLOSED);
}
_sendResponse(response) {
if (this._requestExported)
this._request.unexport();
this._requestExported = false;
let results = {};
if (response === DialogResponse.OK) {
for (let [id, check] of this._choices) {
let checked = check.checked ? 'true' : 'false';
results[id] = new GLib.Variant('s', checked);
}
}
// Delay actual response until the end of the close animation (if any)
this.connect('closed', () => {
this._invocation.return_value(
new GLib.Variant('(ua{sv})', [response, results]));
});
this.close();
}
});
export class AccessDialogDBus {
constructor() {
this._accessDialog = null;
this._windowTracker = Shell.WindowTracker.get_default();
this._senderChecker = new DBusSenderChecker(ALLOWED_SENDERS);
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AccessIface, this);
this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/portal/desktop');
}
async AccessDialogAsync(params, invocation) {
try {
await this._senderChecker.checkInvocation(invocation);
} catch (e) {
invocation.return_gerror(e);
return;
}
if (this._accessDialog) {
invocation.return_error_literal(
Gio.DBusError,
Gio.DBusError.LIMITS_EXCEEDED,
'Already showing a system access dialog');
return;
}
let [handle, appId, parentWindow_, title, description, body, options] = params;
// We probably want to use parentWindow and global.display.focus_window
// for this check in the future
if (appId && `${appId}.desktop` !== this._windowTracker.focus_app.id) {
invocation.return_error_literal(
Gio.DBusError,
Gio.DBusError.ACCESS_DENIED,
'Only the focused app is allowed to show a system access dialog');
return;
}
let dialog = new AccessDialog(
invocation, handle, title, description, body, options);
dialog.open();
dialog.connect('closed', () => (this._accessDialog = null));
this._accessDialog = dialog;
}
}

1144
js/ui/altTab.js Normal file

File diff suppressed because it is too large Load diff

73
js/ui/animation.js Normal file
View file

@ -0,0 +1,73 @@
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as Params from '../misc/params.js';
const SPINNER_ANIMATION_TIME = 300;
const SPINNER_ANIMATION_DELAY = 1000;
export const Spinner = GObject.registerClass(
class Spinner extends St.Widget {
constructor(size, params) {
params = Params.parse(params, {
animate: false,
hideOnStop: false,
});
super({
opacity: 0,
});
this._size = size;
this._animate = params.animate;
this._hideOnStop = params.hideOnStop;
this.visible = !this._hideOnStop;
}
vfunc_map() {
const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage);
const logicalSize = this._size * scaleFactor;
this.set_size(logicalSize, logicalSize);
super.vfunc_map();
}
play() {
this.remove_all_transitions();
this.set_content(new St.SpinnerContent());
this.show();
if (this._animate) {
this.ease({
opacity: 255,
delay: SPINNER_ANIMATION_DELAY,
duration: SPINNER_ANIMATION_TIME,
mode: Clutter.AnimationMode.LINEAR,
});
} else {
this.opacity = 255;
}
}
stop() {
this.remove_all_transitions();
if (this._animate) {
this.ease({
opacity: 0,
duration: SPINNER_ANIMATION_TIME,
mode: Clutter.AnimationMode.LINEAR,
onComplete: () => {
this.set_content(null);
if (this._hideOnStop)
this.hide();
},
});
} else {
this.opacity = 0;
this.set_content(null);
if (this._hideOnStop)
this.hide();
}
}
});

3235
js/ui/appDisplay.js Normal file

File diff suppressed because it is too large Load diff

222
js/ui/appFavorites.js Normal file
View file

@ -0,0 +1,222 @@
import * as MessageTray from './messageTray.js';
import Shell from 'gi://Shell';
import * as ParentalControlsManager from '../misc/parentalControlsManager.js';
import * as Signals from '../misc/signals.js';
// In alphabetical order
const RENAMED_DESKTOP_IDS = {
'baobab.desktop': 'org.gnome.baobab.desktop',
'cheese.desktop': 'org.gnome.Cheese.desktop',
'dconf-editor.desktop': 'ca.desrt.dconf-editor.desktop',
'empathy.desktop': 'org.gnome.Empathy.desktop',
'eog.desktop': 'org.gnome.eog.desktop',
'epiphany.desktop': 'org.gnome.Epiphany.desktop',
'evolution.desktop': 'org.gnome.Evolution.desktop',
'file-roller.desktop': 'org.gnome.FileRoller.desktop',
'five-or-more.desktop': 'org.gnome.five-or-more.desktop',
'four-in-a-row.desktop': 'org.gnome.Four-in-a-row.desktop',
'gcalctool.desktop': 'org.gnome.Calculator.desktop',
'geary.desktop': 'org.gnome.Geary.desktop',
'gedit.desktop': 'org.gnome.gedit.desktop',
'glchess.desktop': 'org.gnome.Chess.desktop',
'glines.desktop': 'org.gnome.five-or-more.desktop',
'gnect.desktop': 'org.gnome.Four-in-a-row.desktop',
'gnibbles.desktop': 'org.gnome.Nibbles.desktop',
'gnobots2.desktop': 'org.gnome.Robots.desktop',
'gnome-boxes.desktop': 'org.gnome.Boxes.desktop',
'gnome-calculator.desktop': 'org.gnome.Calculator.desktop',
'gnome-chess.desktop': 'org.gnome.Chess.desktop',
'gnome-clocks.desktop': 'org.gnome.clocks.desktop',
'gnome-contacts.desktop': 'org.gnome.Contacts.desktop',
'gnome-documents.desktop': 'org.gnome.Documents.desktop',
'gnome-font-viewer.desktop': 'org.gnome.font-viewer.desktop',
'gnome-klotski.desktop': 'org.gnome.Klotski.desktop',
'gnome-nibbles.desktop': 'org.gnome.Nibbles.desktop',
'gnome-mahjongg.desktop': 'org.gnome.Mahjongg.desktop',
'gnome-mines.desktop': 'org.gnome.Mines.desktop',
'gnome-music.desktop': 'org.gnome.Music.desktop',
'gnome-photos.desktop': 'org.gnome.Photos.desktop',
'gnome-robots.desktop': 'org.gnome.Robots.desktop',
'gnome-screenshot.desktop': 'org.gnome.Screenshot.desktop',
'gnome-software.desktop': 'org.gnome.Software.desktop',
'gnome-terminal.desktop': 'org.gnome.Terminal.desktop',
'gnome-tetravex.desktop': 'org.gnome.Tetravex.desktop',
'gnome-tweaks.desktop': 'org.gnome.tweaks.desktop',
'gnome-weather.desktop': 'org.gnome.Weather.desktop',
'gnomine.desktop': 'org.gnome.Mines.desktop',
'gnotravex.desktop': 'org.gnome.Tetravex.desktop',
'gnotski.desktop': 'org.gnome.Klotski.desktop',
'gtali.desktop': 'org.gnome.Tali.desktop',
'iagno.desktop': 'org.gnome.Reversi.desktop',
'nautilus.desktop': 'org.gnome.Nautilus.desktop',
'org.gnome.gnome-2048.desktop': 'org.gnome.TwentyFortyEight.desktop',
'org.gnome.taquin.desktop': 'org.gnome.Taquin.desktop',
'org.gnome.Weather.Application.desktop': 'org.gnome.Weather.desktop',
'polari.desktop': 'org.gnome.Polari.desktop',
'seahorse.desktop': 'org.gnome.seahorse.Application.desktop',
'shotwell.desktop': 'org.gnome.Shotwell.desktop',
'simple-scan.desktop': 'org.gnome.SimpleScan.desktop',
'tali.desktop': 'org.gnome.Tali.desktop',
'totem.desktop': 'org.gnome.Totem.desktop',
'evince.desktop': 'org.gnome.Evince.desktop',
};
class AppFavorites extends Signals.EventEmitter {
constructor() {
super();
// Filter the apps through the users parental controls.
this._parentalControlsManager = ParentalControlsManager.getDefault();
this._parentalControlsManager.connect('app-filter-changed', () => {
this.reload();
this.emit('changed');
});
this.FAVORITE_APPS_KEY = 'favorite-apps';
this._favorites = {};
global.settings.connect(`changed::${this.FAVORITE_APPS_KEY}`, this._onFavsChanged.bind(this));
this.reload();
}
_onFavsChanged() {
this.reload();
this.emit('changed');
}
reload() {
let ids = global.settings.get_strv(this.FAVORITE_APPS_KEY);
let appSys = Shell.AppSystem.get_default();
// Map old desktop file names to the current ones
let updated = false;
ids = ids.map(id => {
let newId = RENAMED_DESKTOP_IDS[id];
if (newId !== undefined &&
appSys.lookup_app(newId) != null) {
updated = true;
return newId;
}
return id;
});
// ... and write back the updated desktop file names
if (updated)
global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
let apps = ids.map(id => appSys.lookup_app(id))
.filter(app => app !== null && this._parentalControlsManager.shouldShowApp(app.app_info));
this._favorites = {};
for (let i = 0; i < apps.length; i++) {
let app = apps[i];
this._favorites[app.get_id()] = app;
}
}
_getIds() {
let ret = [];
for (let id in this._favorites)
ret.push(id);
return ret;
}
getFavoriteMap() {
return this._favorites;
}
getFavorites() {
let ret = [];
for (let id in this._favorites)
ret.push(this._favorites[id]);
return ret;
}
isFavorite(appId) {
return appId in this._favorites;
}
_addFavorite(appId, pos) {
if (appId in this._favorites)
return false;
let app = Shell.AppSystem.get_default().lookup_app(appId);
if (!app)
return false;
if (!this._parentalControlsManager.shouldShowApp(app.app_info))
return false;
let ids = this._getIds();
if (pos === -1)
ids.push(appId);
else
ids.splice(pos, 0, appId);
global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
return true;
}
addFavoriteAtPos(appId, pos) {
if (!this._addFavorite(appId, pos))
return;
const app = Shell.AppSystem.get_default().lookup_app(appId);
this._showNotification(_('%s has been pinned to the dash.').format(app.get_name()),
null,
() => this._removeFavorite(appId));
}
addFavorite(appId) {
this.addFavoriteAtPos(appId, -1);
}
moveFavoriteToPos(appId, pos) {
this._removeFavorite(appId);
this._addFavorite(appId, pos);
}
_removeFavorite(appId) {
if (!(appId in this._favorites))
return false;
let ids = this._getIds().filter(id => id !== appId);
global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
return true;
}
removeFavorite(appId) {
let ids = this._getIds();
let pos = ids.indexOf(appId);
let app = this._favorites[appId];
if (!this._removeFavorite(appId))
return;
this._showNotification(_('%s has been unpinned from the dash.').format(app.get_name()),
null,
() => this._addFavorite(appId, pos));
}
_showNotification(title, body, undoCallback) {
const source = MessageTray.getSystemSource();
const notification = new MessageTray.Notification({
source,
title,
body,
isTransient: true,
forFeedback: true,
});
notification.addAction(_('Undo'), () => undoCallback());
source.addNotification(notification);
}
}
var appFavoritesInstance = null;
/**
* @returns {AppFavorites}
*/
export function getAppFavorites() {
if (appFavoritesInstance == null)
appFavoritesInstance = new AppFavorites();
return appFavoritesInstance;
}

292
js/ui/appMenu.js Normal file
View file

@ -0,0 +1,292 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as AppFavorites from './appFavorites.js';
import * as Main from './main.js';
import * as ParentalControlsManager from '../misc/parentalControlsManager.js';
import * as PopupMenu from './popupMenu.js';
export class AppMenu extends PopupMenu.PopupMenu {
/**
* @param {Clutter.Actor} sourceActor - actor the menu is attached to
* @param {St.Side} side - arrow side
* @param {object} params - options
* @param {bool} params.favoritesSection - show items to add/remove favorite
* @param {bool} params.showSingleWindow - show window section for a single window
*/
constructor(sourceActor, side = St.Side.TOP, params = {}) {
if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) {
if (side === St.Side.LEFT)
side = St.Side.RIGHT;
else if (side === St.Side.RIGHT)
side = St.Side.LEFT;
}
super(sourceActor, 0.5, side);
this.actor.add_style_class_name('app-menu');
const {
favoritesSection = false,
showSingleWindows = false,
} = params;
this._app = null;
this._appSystem = Shell.AppSystem.get_default();
this._parentalControlsManager = ParentalControlsManager.getDefault();
this._appFavorites = AppFavorites.getAppFavorites();
this._enableFavorites = favoritesSection;
this._showSingleWindows = showSingleWindows;
this._windowsChangedId = 0;
this._updateWindowsLaterId = 0;
/* Translators: This is the heading of a list of open windows */
this._openWindowsHeader = new PopupMenu.PopupSeparatorMenuItem(_('Open Windows'));
this.addMenuItem(this._openWindowsHeader);
this._windowSection = new PopupMenu.PopupMenuSection();
this.addMenuItem(this._windowSection);
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._newWindowItem = this.addAction(_('New Window'), () => {
this._animateLaunch();
this._app.open_new_window(-1);
Main.overview.hide();
});
this._actionSection = new PopupMenu.PopupMenuSection();
this.addMenuItem(this._actionSection);
this._onGpuMenuItem = this.addAction('', () => {
this._animateLaunch();
this._app.launch(0, -1, this._getNonDefaultLaunchGpu());
Main.overview.hide();
});
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._toggleFavoriteItem = this.addAction('', () => {
const appId = this._app.get_id();
if (this._appFavorites.isFavorite(appId))
this._appFavorites.removeFavorite(appId);
else
this._appFavorites.addFavorite(appId);
});
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._detailsItem = this.addAction(_('App Details'), async () => {
const id = this._app.get_id();
const args = GLib.Variant.new('(ss)', [id, '']);
const bus = await Gio.DBus.get(Gio.BusType.SESSION, null);
bus.call(
'org.gnome.Software',
'/org/gnome/Software',
'org.gtk.Actions', 'Activate',
new GLib.Variant('(sava{sv})', ['details', [args], null]),
null, 0, -1, null);
Main.overview.hide();
});
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._quitItem =
this.addAction(_('Quit'), () => this._app.request_quit());
this._appSystem.connectObject(
'installed-changed', () => this._updateDetailsVisibility(),
'app-state-changed', this._onAppStateChanged.bind(this),
this.actor);
this._parentalControlsManager.connectObject(
'app-filter-changed', () => this._updateFavoriteItem(), this.actor);
this._appFavorites.connectObject(
'changed', () => this._updateFavoriteItem(), this.actor);
global.settings.connectObject(
'writable-changed::favorite-apps', () => this._updateFavoriteItem(),
this.actor);
global.connectObject(
'notify::switcheroo-control', () => this._updateGpuItem(),
this.actor);
this._updateQuitItem();
this._updateFavoriteItem();
this._updateGpuItem();
this._updateDetailsVisibility();
}
_onAppStateChanged(sys, app) {
if (this._app !== app)
return;
this._updateQuitItem();
this._updateNewWindowItem();
this._updateGpuItem();
}
_updateQuitItem() {
this._quitItem.visible = this._app?.state === Shell.AppState.RUNNING;
}
_updateNewWindowItem() {
const actions = this._app?.appInfo?.list_actions() ?? [];
this._newWindowItem.visible =
this._app?.can_open_new_window() && !actions.includes('new-window');
}
_updateFavoriteItem() {
const appInfo = this._app?.app_info;
const canFavorite = appInfo &&
this._enableFavorites &&
global.settings.is_writable('favorite-apps') &&
this._parentalControlsManager.shouldShowApp(appInfo);
this._toggleFavoriteItem.visible = canFavorite;
if (!canFavorite)
return;
const {id} = this._app;
this._toggleFavoriteItem.label.text = this._appFavorites.isFavorite(id)
? _('Unpin')
: _('Pin to Dash');
}
_updateGpuItem() {
const proxy = global.get_switcheroo_control();
const hasDualGpu = proxy?.get_cached_property('HasDualGpu')?.unpack();
const showItem =
this._app?.state === Shell.AppState.STOPPED && hasDualGpu;
this._onGpuMenuItem.visible = showItem;
if (!showItem)
return;
const launchGpu = this._getNonDefaultLaunchGpu();
this._onGpuMenuItem.label.text = launchGpu === Shell.AppLaunchGpu.DEFAULT
? _('Launch using Integrated Graphics Card')
: _('Launch using Discrete Graphics Card');
}
_updateDetailsVisibility() {
const sw = this._appSystem.lookup_app('org.gnome.Software.desktop');
this._detailsItem.visible = sw !== null;
}
_animateLaunch() {
if (this.sourceActor.animateLaunch)
this.sourceActor.animateLaunch();
}
_getNonDefaultLaunchGpu() {
return this._app.appInfo.get_boolean('PrefersNonDefaultGPU')
? Shell.AppLaunchGpu.DEFAULT
: Shell.AppLaunchGpu.DISCRETE;
}
/** */
destroy() {
this.setApp(null);
super.destroy();
}
/**
* @returns {bool} - true if the menu is empty
*/
isEmpty() {
if (!this._app)
return true;
return super.isEmpty();
}
/**
* @param {Shell.App} app - the app the menu represents
*/
setApp(app) {
if (this._app === app)
return;
this._app?.disconnectObject(this);
this._app = app;
this._app?.connectObject('windows-changed',
() => this._queueUpdateWindowsSection(), this);
this._updateWindowsSection();
const appInfo = app?.app_info;
const actions = appInfo?.list_actions() ?? [];
this._actionSection.removeAll();
actions.forEach(action => {
const label = appInfo.get_action_name(action);
this._actionSection.addAction(label, event => {
if (action === 'new-window')
this._animateLaunch();
this._app.launch_action(action, event.get_time(), -1);
Main.overview.hide();
});
});
this._updateQuitItem();
this._updateNewWindowItem();
this._updateFavoriteItem();
this._updateGpuItem();
}
_queueUpdateWindowsSection() {
if (this._updateWindowsLaterId)
return;
const laters = global.compositor.get_laters();
this._updateWindowsLaterId = laters.add(
Meta.LaterType.BEFORE_REDRAW, () => {
this._updateWindowsSection();
return GLib.SOURCE_REMOVE;
});
}
_updateWindowsSection() {
if (this._updateWindowsLaterId) {
const laters = global.compositor.get_laters();
laters.remove(this._updateWindowsLaterId);
}
this._updateWindowsLaterId = 0;
this._windowSection.removeAll();
this._openWindowsHeader.hide();
if (!this._app)
return;
const minWindows = this._showSingleWindows ? 1 : 2;
const windows = this._app.get_windows().filter(w => !w.skip_taskbar);
if (windows.length < minWindows)
return;
this._openWindowsHeader.show();
windows.forEach(window => {
const title = window.title || this._app.get_name();
const item = this._windowSection.addAction(title, event => {
Main.activateWindow(window, event.get_time());
});
window.connectObject('notify::title', () => {
item.label.text = window.title || this._app.get_name();
}, item);
});
}
}

View file

@ -0,0 +1,215 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Dialog from './dialog.js';
import * as ModalDialog from './modalDialog.js';
import * as Main from './main.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const AudioDevice = {
HEADPHONES: 1 << 0,
HEADSET: 1 << 1,
MICROPHONE: 1 << 2,
};
const AudioDeviceSelectionIface = loadInterfaceXML('org.gnome.Shell.AudioDeviceSelection');
const AudioDeviceSelectionDialog = GObject.registerClass({
Signals: {'device-selected': {param_types: [GObject.TYPE_UINT]}},
}, class AudioDeviceSelectionDialog extends ModalDialog.ModalDialog {
_init(devices) {
super._init({styleClass: 'audio-device-selection-dialog'});
this._deviceItems = {};
this._buildLayout();
if (devices & AudioDevice.HEADPHONES)
this._addDevice(AudioDevice.HEADPHONES);
if (devices & AudioDevice.HEADSET)
this._addDevice(AudioDevice.HEADSET);
if (devices & AudioDevice.MICROPHONE)
this._addDevice(AudioDevice.MICROPHONE);
if (this._selectionBox.get_n_children() < 2)
throw new Error('Too few devices for a selection');
}
_buildLayout() {
let content = new Dialog.MessageDialogContent({
title: _('Select Audio Device'),
});
this._selectionBox = new St.BoxLayout({
style_class: 'audio-selection-box',
x_align: Clutter.ActorAlign.CENTER,
x_expand: true,
});
content.add_child(this._selectionBox);
this.contentLayout.add_child(content);
this.addButton({
action: () => this.close(),
label: _('Cancel'),
key: Clutter.KEY_Escape,
});
if (Main.sessionMode.allowSettings) {
this.addButton({
action: this._openSettings.bind(this),
label: _('Sound Settings'),
});
}
}
_getDeviceLabel(device) {
switch (device) {
case AudioDevice.HEADPHONES:
return _('Headphones');
case AudioDevice.HEADSET:
return _('Headset');
case AudioDevice.MICROPHONE:
return _('Microphone');
default:
return null;
}
}
_getDeviceIcon(device) {
switch (device) {
case AudioDevice.HEADPHONES:
return 'audio-headphones-symbolic';
case AudioDevice.HEADSET:
return 'audio-headset-symbolic';
case AudioDevice.MICROPHONE:
return 'audio-input-microphone-symbolic';
default:
return null;
}
}
_addDevice(device) {
const box = new St.BoxLayout({
style_class: 'audio-selection-device-box',
orientation: Clutter.Orientation.VERTICAL,
});
box.connect('notify::height', () => {
const laters = global.compositor.get_laters();
laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
box.width = box.height;
return GLib.SOURCE_REMOVE;
});
});
const icon = new St.Icon({
style_class: 'audio-selection-device-icon',
icon_name: this._getDeviceIcon(device),
});
box.add_child(icon);
const label = new St.Label({
style_class: 'audio-selection-device-label',
text: this._getDeviceLabel(device),
x_align: Clutter.ActorAlign.CENTER,
});
box.add_child(label);
const button = new St.Button({
style_class: 'audio-selection-device',
can_focus: true,
child: box,
});
this._selectionBox.add_child(button);
button.connect('clicked', () => {
this.emit('device-selected', device);
this.close();
Main.overview.hide();
});
}
_openSettings() {
let desktopFile = 'gnome-sound-panel.desktop';
let app = Shell.AppSystem.get_default().lookup_app(desktopFile);
if (!app) {
log(`Settings panel for desktop file ${desktopFile} could not be loaded!`);
return;
}
this.close();
Main.overview.hide();
app.activate();
}
});
export class AudioDeviceSelectionDBus {
constructor() {
this._audioSelectionDialog = null;
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AudioDeviceSelectionIface, this);
this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/AudioDeviceSelection');
Gio.DBus.session.own_name('org.gnome.Shell.AudioDeviceSelection', Gio.BusNameOwnerFlags.REPLACE, null, null);
}
_onDialogClosed() {
this._audioSelectionDialog = null;
}
_onDeviceSelected(dialog, device) {
let connection = this._dbusImpl.get_connection();
let info = this._dbusImpl.get_info();
const deviceName = Object.keys(AudioDevice)
.filter(dev => AudioDevice[dev] === device)[0].toLowerCase();
connection.emit_signal(
this._audioSelectionDialog._sender,
this._dbusImpl.get_object_path(),
info ? info.name : null,
'DeviceSelected',
GLib.Variant.new('(s)', [deviceName]));
}
OpenAsync(params, invocation) {
if (this._audioSelectionDialog) {
invocation.return_value(null);
return;
}
let [deviceNames] = params;
let devices = 0;
deviceNames.forEach(n => (devices |= AudioDevice[n.toUpperCase()]));
let dialog;
try {
dialog = new AudioDeviceSelectionDialog(devices);
} catch {
invocation.return_value(null);
return;
}
dialog._sender = invocation.get_sender();
dialog.connect('closed', this._onDialogClosed.bind(this));
dialog.connect('device-selected',
this._onDeviceSelected.bind(this));
dialog.open();
this._audioSelectionDialog = dialog;
invocation.return_value(null);
}
CloseAsync(params, invocation) {
if (this._audioSelectionDialog &&
this._audioSelectionDialog._sender === invocation.get_sender())
this._audioSelectionDialog.close();
invocation.return_value(null);
}
}

849
js/ui/background.js Normal file
View file

@ -0,0 +1,849 @@
// READ THIS FIRST
// Background handling is a maze of objects, both objects in this file, and
// also objects inside Mutter. They all have a role.
//
// BackgroundManager
// The only object that other parts of GNOME Shell deal with; a
// BackgroundManager creates background actors and adds them to
// the specified container. When the background is changed by the
// user it will fade out the old actor and fade in the new actor.
// (This is separate from the fading for an animated background,
// since using two actors is quite inefficient.)
//
// MetaBackgroundImage
// An object represented an image file that will be used for drawing
// the background. MetaBackgroundImage objects asynchronously load,
// so they are first created in an unloaded state, then later emit
// a ::loaded signal when the Cogl object becomes available.
//
// MetaBackgroundImageCache
// A cache from filename to MetaBackgroundImage.
//
// BackgroundSource
// An object that is created for each GSettings schema (separate
// settings schemas are used for the lock screen and main background),
// and holds a reference to shared Background objects.
//
// MetaBackground
// Holds the specification of a background - a background color
// or gradient and one or two images blended together.
//
// Background
// JS delegate object that Connects a MetaBackground to the GSettings
// schema for the background.
//
// Animation
// A helper object that handles loading a XML-based animation; it is a
// wrapper for GnomeDesktop.BGSlideShow
//
// MetaBackgroundActor
// An actor that draws the background for a single monitor
//
// BackgroundCache
// A cache of Settings schema => BackgroundSource and of a single Animation.
// Also used to share file monitors.
//
// A static image, background color or gradient is relatively straightforward. The
// calling code creates a separate BackgroundManager for each monitor. Since they
// are created for the same GSettings schema, they will use the same BackgroundSource
// object, which provides a single Background and correspondingly a single
// MetaBackground object.
//
// BackgroundManager BackgroundManager
// | \ / |
// | BackgroundSource | looked up in BackgroundCache
// | | |
// | Background |
// | | |
// MetaBackgroundActor | MetaBackgroundActor
// \ | /
// `------- MetaBackground ------'
// |
// MetaBackgroundImage looked up in MetaBackgroundImageCache
//
// The animated case is tricker because the animation XML file can specify different
// files for different monitor resolutions and aspect ratios. For this reason,
// the BackgroundSource provides different Background share a single Animation object,
// which tracks the animation, but use different MetaBackground objects. In the
// common case, the different MetaBackground objects will be created for the
// same filename and look up the *same* MetaBackgroundImage object, so there is
// little wasted memory:
//
// BackgroundManager BackgroundManager
// | \ / |
// | BackgroundSource | looked up in BackgroundCache
// | / \ |
// | Background Background |
// | | \ / | |
// | | Animation | | looked up in BackgroundCache
// MetaBackgroundA|tor Me|aBackgroundActor
// \ | | /
// MetaBackground MetaBackground
// \ /
// MetaBackgroundImage looked up in MetaBackgroundImageCache
// MetaBackgroundImage
//
// But the case of different filenames and different background images
// is possible as well:
// ....
// MetaBackground MetaBackground
// | |
// MetaBackgroundImage MetaBackgroundImage
// MetaBackgroundImage MetaBackgroundImage
import Clutter from 'gi://Clutter';
import Cogl from 'gi://Cogl';
import GDesktopEnums from 'gi://GDesktopEnums';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import GnomeBG from 'gi://GnomeBG';
import GnomeDesktop from 'gi://GnomeDesktop';
import Meta from 'gi://Meta';
import * as Signals from '../misc/signals.js';
import * as LoginManager from '../misc/loginManager.js';
import * as Main from './main.js';
import * as Params from '../misc/params.js';
const DEFAULT_BACKGROUND_COLOR = new Cogl.Color({red: 40, green: 40, blue: 40, alpha: 255});
const BACKGROUND_SCHEMA = 'org.gnome.desktop.background';
const PRIMARY_COLOR_KEY = 'primary-color';
const SECONDARY_COLOR_KEY = 'secondary-color';
const COLOR_SHADING_TYPE_KEY = 'color-shading-type';
const BACKGROUND_STYLE_KEY = 'picture-options';
const PICTURE_URI_KEY = 'picture-uri';
const PICTURE_URI_DARK_KEY = 'picture-uri-dark';
const INTERFACE_SCHEMA = 'org.gnome.desktop.interface';
const COLOR_SCHEME_KEY = 'color-scheme';
const FADE_ANIMATION_TIME = 1000;
// These parameters affect how often we redraw.
// The first is how different (percent crossfaded) the slide show
// has to look before redrawing and the second is the minimum
// frequency (in seconds) we're willing to wake up
const ANIMATION_OPACITY_STEP_INCREMENT = 4.0;
const ANIMATION_MIN_WAKEUP_INTERVAL = 1.0;
let _backgroundCache = null;
function _fileEqual0(file1, file2) {
if (file1 === file2)
return true;
if (!file1 || !file2)
return false;
return file1.equal(file2);
}
class BackgroundCache extends Signals.EventEmitter {
constructor() {
super();
this._fileMonitors = {};
this._backgroundSources = {};
this._animations = {};
}
monitorFile(file) {
let key = file.hash();
if (this._fileMonitors[key])
return;
let monitor = file.monitor(Gio.FileMonitorFlags.NONE, null);
monitor.connect('changed',
(obj, theFile, otherFile, eventType) => {
// Ignore CHANGED and CREATED events, since in both cases
// we'll get a CHANGES_DONE_HINT event when done.
if (eventType !== Gio.FileMonitorEvent.CHANGED &&
eventType !== Gio.FileMonitorEvent.CREATED)
this.emit('file-changed', file);
});
this._fileMonitors[key] = monitor;
}
getAnimation(params) {
params = Params.parse(params, {
file: null,
settingsSchema: null,
onLoaded: null,
});
let animation = this._animations[params.settingsSchema];
if (animation && _fileEqual0(animation.file, params.file)) {
if (params.onLoaded) {
let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
params.onLoaded(this._animations[params.settingsSchema]);
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded');
}
return;
}
animation = new Animation({file: params.file});
animation.load_async(null, () => {
this._animations[params.settingsSchema] = animation;
if (params.onLoaded) {
let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
params.onLoaded(this._animations[params.settingsSchema]);
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded');
}
});
}
getBackgroundSource(layoutManager, settingsSchema) {
// The layoutManager is always the same one; we pass in it since
// Main.layoutManager may not be set yet
if (!(settingsSchema in this._backgroundSources)) {
this._backgroundSources[settingsSchema] = new BackgroundSource(layoutManager, settingsSchema);
this._backgroundSources[settingsSchema]._useCount = 1;
} else {
this._backgroundSources[settingsSchema]._useCount++;
}
return this._backgroundSources[settingsSchema];
}
releaseBackgroundSource(settingsSchema) {
if (settingsSchema in this._backgroundSources) {
let source = this._backgroundSources[settingsSchema];
source._useCount--;
if (source._useCount === 0) {
delete this._backgroundSources[settingsSchema];
source.destroy();
}
}
}
}
/**
* @returns {BackgroundCache}
*/
function getBackgroundCache() {
if (!_backgroundCache)
_backgroundCache = new BackgroundCache();
return _backgroundCache;
}
const Background = GObject.registerClass({
Signals: {'loaded': {}, 'bg-changed': {}},
}, class Background extends Meta.Background {
_init(params) {
params = Params.parse(params, {
monitorIndex: 0,
layoutManager: Main.layoutManager,
settings: null,
file: null,
style: null,
});
super._init({meta_display: global.display});
this._settings = params.settings;
this._file = params.file;
this._style = params.style;
this._monitorIndex = params.monitorIndex;
this._layoutManager = params.layoutManager;
this._fileWatches = {};
this._cancellable = new Gio.Cancellable();
this.isLoaded = false;
this._interfaceSettings = new Gio.Settings({schema_id: INTERFACE_SCHEMA});
this._clock = new GnomeDesktop.WallClock();
this._clock.connectObject('notify::timezone',
() => {
if (this._animation)
this._loadAnimation(this._animation.file);
}, this);
let loginManager = LoginManager.getLoginManager();
loginManager.connectObject('prepare-for-sleep',
(lm, aboutToSuspend) => {
if (aboutToSuspend)
return;
this._refreshAnimation();
}, this);
this._settings.connectObject('changed',
this._emitChangedSignal.bind(this), this);
this._interfaceSettings.connectObject(`changed::${COLOR_SCHEME_KEY}`,
this._emitChangedSignal.bind(this), this);
this._load();
}
destroy() {
this._cancellable.cancel();
this._removeAnimationTimeout();
let i;
let keys = Object.keys(this._fileWatches);
for (i = 0; i < keys.length; i++)
this._cache.disconnect(this._fileWatches[keys[i]]);
this._fileWatches = null;
this._clock.disconnectObject(this);
this._clock = null;
LoginManager.getLoginManager().disconnectObject(this);
this._settings.disconnectObject(this);
this._interfaceSettings.disconnectObject(this);
if (this._changedIdleId) {
GLib.source_remove(this._changedIdleId);
this._changedIdleId = 0;
}
}
_emitChangedSignal() {
if (this._changedIdleId)
return;
this._changedIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this._changedIdleId = 0;
this.emit('bg-changed');
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._changedIdleId,
'[gnome-shell] Background._emitChangedSignal');
}
updateResolution() {
if (this._animation)
this._refreshAnimation();
}
_refreshAnimation() {
if (!this._animation)
return;
this._removeAnimationTimeout();
this._updateAnimation();
}
_setLoaded() {
if (this.isLoaded)
return;
this.isLoaded = true;
if (this._cancellable?.is_cancelled())
return;
let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this.emit('loaded');
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(id, '[gnome-shell] Background._setLoaded Idle');
}
_loadPattern() {
let colorString, res_, color, secondColor;
colorString = this._settings.get_string(PRIMARY_COLOR_KEY);
[res_, color] = Cogl.Color.from_string(colorString);
colorString = this._settings.get_string(SECONDARY_COLOR_KEY);
[res_, secondColor] = Cogl.Color.from_string(colorString);
let shadingType = this._settings.get_enum(COLOR_SHADING_TYPE_KEY);
if (shadingType === GDesktopEnums.BackgroundShading.SOLID)
this.set_color(color);
else
this.set_gradient(shadingType, color, secondColor);
}
_watchFile(file) {
let key = file.hash();
if (this._fileWatches[key])
return;
this._cache.monitorFile(file);
let signalId = this._cache.connect('file-changed',
(cache, changedFile) => {
if (changedFile.equal(file)) {
let imageCache = Meta.BackgroundImageCache.get_default();
imageCache.purge(changedFile);
this._emitChangedSignal();
}
});
this._fileWatches[key] = signalId;
}
_removeAnimationTimeout() {
if (this._updateAnimationTimeoutId) {
GLib.source_remove(this._updateAnimationTimeoutId);
this._updateAnimationTimeoutId = 0;
}
}
_updateAnimation() {
this._updateAnimationTimeoutId = 0;
this._animation.update(this._layoutManager.monitors[this._monitorIndex]);
let files = this._animation.keyFrameFiles;
let finish = () => {
this._setLoaded();
if (files.length > 1) {
this.set_blend(files[0], files[1],
this._animation.transitionProgress,
this._style);
} else if (files.length > 0) {
this.set_file(files[0], this._style);
} else {
this.set_file(null, this._style);
}
this._queueUpdateAnimation();
};
let cache = Meta.BackgroundImageCache.get_default();
let numPendingImages = files.length;
for (let i = 0; i < files.length; i++) {
this._watchFile(files[i]);
let image = cache.load(files[i]);
if (image.is_loaded()) {
numPendingImages--;
if (numPendingImages === 0)
finish();
} else {
// eslint-disable-next-line no-loop-func
let id = image.connect('loaded', () => {
image.disconnect(id);
numPendingImages--;
if (numPendingImages === 0)
finish();
});
}
}
}
_queueUpdateAnimation() {
if (this._updateAnimationTimeoutId !== 0)
return;
if (!this._cancellable || this._cancellable.is_cancelled())
return;
if (!this._animation.transitionDuration)
return;
let nSteps = 255 / ANIMATION_OPACITY_STEP_INCREMENT;
let timePerStep = (this._animation.transitionDuration * 1000) / nSteps;
let interval = Math.max(
ANIMATION_MIN_WAKEUP_INTERVAL * 1000,
timePerStep);
if (interval > GLib.MAXUINT32)
return;
this._updateAnimationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
interval,
() => {
this._updateAnimationTimeoutId = 0;
this._updateAnimation();
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._updateAnimationTimeoutId, '[gnome-shell] this._updateAnimation');
}
_loadAnimation(file) {
this._cache.getAnimation({
file,
settingsSchema: this._settings.schema_id,
onLoaded: animation => {
this._animation = animation;
if (!this._animation || this._cancellable.is_cancelled()) {
this._setLoaded();
return;
}
this._updateAnimation();
this._watchFile(file);
},
});
}
_loadImage(file) {
this.set_file(file, this._style);
this._watchFile(file);
let cache = Meta.BackgroundImageCache.get_default();
let image = cache.load(file);
if (image.is_loaded()) {
this._setLoaded();
} else {
let id = image.connect('loaded', () => {
this._setLoaded();
image.disconnect(id);
});
}
}
async _loadFile(file) {
let info;
try {
info = await file.query_info_async(
Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
Gio.FileQueryInfoFlags.NONE,
0,
this._cancellable);
} catch {
this._setLoaded();
return;
}
const contentType = info.get_content_type();
if (contentType === 'application/xml')
this._loadAnimation(file);
else
this._loadImage(file);
}
_load() {
this._cache = getBackgroundCache();
this._loadPattern();
if (!this._file) {
this._setLoaded();
return;
}
this._loadFile(this._file);
}
});
let _systemBackground;
export const SystemBackground = GObject.registerClass({
Signals: {'loaded': {}},
}, class SystemBackground extends Meta.BackgroundActor {
_init() {
if (_systemBackground == null) {
_systemBackground = new Meta.Background({meta_display: global.display});
_systemBackground.set_color(DEFAULT_BACKGROUND_COLOR);
}
super._init({
meta_display: global.display,
monitor: 0,
});
this.content.background = _systemBackground;
let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this.emit('loaded');
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(id, '[gnome-shell] SystemBackground.loaded');
}
});
class BackgroundSource {
constructor(layoutManager, settingsSchema) {
// Allow override the background image setting for performance testing
this._layoutManager = layoutManager;
this._overrideImage = GLib.getenv('SHELL_BACKGROUND_IMAGE');
this._settings = new Gio.Settings({schema_id: settingsSchema});
this._backgrounds = [];
const monitorManager = global.backend.get_monitor_manager();
this._monitorsChangedId =
monitorManager.connect('monitors-changed',
this._onMonitorsChanged.bind(this));
this._interfaceSettings = new Gio.Settings({schema_id: INTERFACE_SCHEMA});
}
_onMonitorsChanged() {
for (let monitorIndex in this._backgrounds) {
let background = this._backgrounds[monitorIndex];
if (monitorIndex < this._layoutManager.monitors.length) {
background.updateResolution();
} else {
background.disconnect(background._changedId);
background.destroy();
delete this._backgrounds[monitorIndex];
}
}
}
getBackground(monitorIndex) {
let file = null;
let style;
// We don't watch changes to settings here,
// instead we rely on Background to watch those
// and emit 'bg-changed' at the right time
if (this._overrideImage != null) {
file = Gio.File.new_for_path(this._overrideImage);
style = GDesktopEnums.BackgroundStyle.ZOOM; // Hardcode
} else {
style = this._settings.get_enum(BACKGROUND_STYLE_KEY);
if (style !== GDesktopEnums.BackgroundStyle.NONE) {
const colorScheme = this._interfaceSettings.get_enum('color-scheme');
const uri = this._settings.get_string(
colorScheme === GDesktopEnums.ColorScheme.PREFER_DARK
? PICTURE_URI_DARK_KEY
: PICTURE_URI_KEY);
file = Gio.File.new_for_commandline_arg(uri);
}
}
// Animated backgrounds are (potentially) per-monitor, since
// they can have variants that depend on the aspect ratio and
// size of the monitor; for other backgrounds we can use the
// same background object for all monitors.
if (file == null || !file.get_basename().endsWith('.xml'))
monitorIndex = 0;
if (!(monitorIndex in this._backgrounds)) {
let background = new Background({
monitorIndex,
layoutManager: this._layoutManager,
settings: this._settings,
file,
style,
});
background._changedId = background.connect('bg-changed', () => {
background.disconnect(background._changedId);
background.destroy();
delete this._backgrounds[monitorIndex];
});
this._backgrounds[monitorIndex] = background;
}
return this._backgrounds[monitorIndex];
}
destroy() {
const monitorManager = global.backend.get_monitor_manager();
monitorManager.disconnect(this._monitorsChangedId);
for (let monitorIndex in this._backgrounds) {
let background = this._backgrounds[monitorIndex];
background.disconnect(background._changedId);
background.destroy();
}
this._backgrounds = null;
}
}
const Animation = GObject.registerClass(
class Animation extends GnomeBG.BGSlideShow {
_init(params) {
super._init(params);
this.keyFrameFiles = [];
this.transitionProgress = 0.0;
this.transitionDuration = 0.0;
this.loaded = false;
}
// eslint-disable-next-line camelcase
load_async(cancellable, callback) {
super.load_async(cancellable, () => {
this.loaded = true;
callback?.();
});
}
update(monitor) {
this.keyFrameFiles = [];
if (this.get_num_slides() < 1)
return;
let [progress, duration, isFixed_, filename1, filename2] =
this.get_current_slide(monitor.width, monitor.height);
this.transitionDuration = duration;
this.transitionProgress = progress;
if (filename1)
this.keyFrameFiles.push(Gio.File.new_for_path(filename1));
if (filename2)
this.keyFrameFiles.push(Gio.File.new_for_path(filename2));
}
});
export class BackgroundManager extends Signals.EventEmitter {
constructor(params) {
super();
params = Params.parse(params, {
container: null,
layoutManager: Main.layoutManager,
monitorIndex: null,
vignette: false,
controlPosition: true,
settingsSchema: BACKGROUND_SCHEMA,
useContentSize: true,
});
let cache = getBackgroundCache();
this._settingsSchema = params.settingsSchema;
this._backgroundSource = cache.getBackgroundSource(params.layoutManager, params.settingsSchema);
this._container = params.container;
this._layoutManager = params.layoutManager;
this._vignette = params.vignette;
this._monitorIndex = params.monitorIndex;
this._controlPosition = params.controlPosition;
this._useContentSize = params.useContentSize;
this.backgroundActor = this._createBackgroundActor();
this._newBackgroundActor = null;
}
destroy() {
let cache = getBackgroundCache();
cache.releaseBackgroundSource(this._settingsSchema);
this._backgroundSource = null;
if (this._newBackgroundActor) {
this._newBackgroundActor.destroy();
this._newBackgroundActor = null;
}
if (this.backgroundActor) {
this.backgroundActor.destroy();
this.backgroundActor = null;
}
}
_swapBackgroundActor() {
let oldBackgroundActor = this.backgroundActor;
this.backgroundActor = this._newBackgroundActor;
this._newBackgroundActor = null;
this.emit('changed');
if (Main.layoutManager.screenTransition.visible) {
oldBackgroundActor.destroy();
return;
}
oldBackgroundActor.ease({
opacity: 0,
duration: FADE_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => oldBackgroundActor.destroy(),
});
}
_updateBackgroundActor() {
if (this._newBackgroundActor) {
/* Skip displaying existing background queued for load */
this._newBackgroundActor.destroy();
this._newBackgroundActor = null;
}
let newBackgroundActor = this._createBackgroundActor();
const oldContent = this.backgroundActor.content;
const newContent = newBackgroundActor.content;
newContent.vignette_sharpness = oldContent.vignette_sharpness;
newContent.brightness = oldContent.brightness;
newBackgroundActor.visible = this.backgroundActor.visible;
this._newBackgroundActor = newBackgroundActor;
const {background} = newBackgroundActor.content;
if (background.isLoaded) {
this._swapBackgroundActor();
} else {
newBackgroundActor.loadedSignalId = background.connect('loaded',
() => {
background.disconnect(newBackgroundActor.loadedSignalId);
newBackgroundActor.loadedSignalId = 0;
this._swapBackgroundActor();
});
}
}
_createBackgroundActor() {
let background = this._backgroundSource.getBackground(this._monitorIndex);
let backgroundActor = new Meta.BackgroundActor({
meta_display: global.display,
monitor: this._monitorIndex,
request_mode: this._useContentSize
? Clutter.RequestMode.CONTENT_SIZE
: Clutter.RequestMode.HEIGHT_FOR_WIDTH,
x_expand: !this._useContentSize,
y_expand: !this._useContentSize,
});
backgroundActor.content.set({
background,
vignette: this._vignette,
vignette_sharpness: 0.5,
brightness: 0.5,
});
this._container.add_child(backgroundActor);
if (this._controlPosition) {
let monitor = this._layoutManager.monitors[this._monitorIndex];
backgroundActor.set_position(monitor.x, monitor.y);
this._container.set_child_below_sibling(backgroundActor, null);
}
let changeSignalId = background.connect('bg-changed', () => {
background.disconnect(changeSignalId);
changeSignalId = null;
this._updateBackgroundActor();
});
let loadedSignalId;
if (background.isLoaded) {
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
this.emit('loaded');
return GLib.SOURCE_REMOVE;
});
} else {
loadedSignalId = background.connect('loaded', () => {
background.disconnect(loadedSignalId);
loadedSignalId = null;
this.emit('loaded');
});
}
backgroundActor.connect('destroy', () => {
if (changeSignalId)
background.disconnect(changeSignalId);
if (loadedSignalId)
background.disconnect(loadedSignalId);
if (backgroundActor.loadedSignalId)
background.disconnect(backgroundActor.loadedSignalId);
});
return backgroundActor;
}
}

67
js/ui/backgroundMenu.js Normal file
View file

@ -0,0 +1,67 @@
import Clutter from 'gi://Clutter';
import St from 'gi://St';
import * as BoxPointer from './boxpointer.js';
import * as PopupMenu from './popupMenu.js';
import * as Main from './main.js';
export class BackgroundMenu extends PopupMenu.PopupMenu {
constructor(layoutManager) {
super(layoutManager.dummyCursor, 0, St.Side.TOP);
this.addSettingsAction(_('Change Background…'), 'gnome-background-panel.desktop');
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this.addSettingsAction(_('Display Settings'), 'gnome-display-panel.desktop');
this.addSettingsAction(_('Settings'), 'org.gnome.Settings.desktop');
this.actor.add_style_class_name('background-menu');
layoutManager.uiGroup.add_child(this.actor);
this.actor.hide();
}
}
/**
* @param {Meta.BackgroundActor} actor
* @param {import('./layout.js').LayoutManager} layoutManager
*/
export function addBackgroundMenu(actor, layoutManager) {
actor.reactive = true;
actor._backgroundMenu = new BackgroundMenu(layoutManager);
actor._backgroundManager = new PopupMenu.PopupMenuManager(actor);
actor._backgroundManager.addMenu(actor._backgroundMenu);
function openMenu(x, y) {
Main.layoutManager.setDummyCursorGeometry(x, y, 0, 0);
actor._backgroundMenu.open(BoxPointer.PopupAnimation.FULL);
}
let clickAction = new Clutter.ClickAction();
clickAction.connect('long-press', (action, theActor, state) => {
if (state === Clutter.LongPressState.QUERY) {
return (action.get_button() === 0 ||
action.get_button() === 1) &&
!actor._backgroundMenu.isOpen;
}
if (state === Clutter.LongPressState.ACTIVATE) {
let [x, y] = action.get_coords();
openMenu(x, y);
actor._backgroundManager.ignoreRelease();
}
return true;
});
clickAction.connect('clicked', action => {
if (action.get_button() === 3) {
let [x, y] = action.get_coords();
openMenu(x, y);
}
});
actor.add_action(clickAction);
actor.connect('destroy', () => {
actor._backgroundMenu.destroy();
actor._backgroundMenu = null;
actor._backgroundManager = null;
});
}

274
js/ui/barLevel.js Normal file
View file

@ -0,0 +1,274 @@
import Atk from 'gi://Atk';
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import St from 'gi://St';
export const BarLevel = GObject.registerClass({
Properties: {
'value': GObject.ParamSpec.double(
'value', null, null,
GObject.ParamFlags.READWRITE,
0, 2, 0),
'maximum-value': GObject.ParamSpec.double(
'maximum-value', null, null,
GObject.ParamFlags.READWRITE,
1, 2, 1),
'overdrive-start': GObject.ParamSpec.double(
'overdrive-start', null, null,
GObject.ParamFlags.READWRITE,
1, 2, 1),
},
}, class BarLevel extends St.DrawingArea {
_init(params) {
this._maxValue = 1;
this._value = 0;
this._overdriveStart = 1;
this._barLevelWidth = 0;
this._barLevelHeight = 0;
this._overdriveSeparatorWidth = 0;
this._barLevelColor = null;
this._barLevelActiveColor = null;
this._barLevelOverdriveColor = null;
super._init({
style_class: 'barlevel',
accessible_role: Atk.Role.LEVEL_BAR,
...params,
});
this.connect('notify::allocation', () => {
this._barLevelWidth = this.allocation.get_width();
});
this._customAccessible = St.GenericAccessible.new_for_actor(this);
this.set_accessible(this._customAccessible);
this._customAccessible.connect('get-current-value', this._getCurrentValue.bind(this));
this._customAccessible.connect('get-minimum-value', this._getMinimumValue.bind(this));
this._customAccessible.connect('get-maximum-value', this._getMaximumValue.bind(this));
this._customAccessible.connect('set-current-value', this._setCurrentValue.bind(this));
this.connect('notify::value', this._valueChanged.bind(this));
}
get value() {
return this._value;
}
set value(value) {
value = Math.max(Math.min(value, this._maxValue), 0);
if (this._value === value)
return;
this._value = value;
this.notify('value');
this.queue_repaint();
}
get maximumValue() {
return this._maxValue;
}
set maximumValue(value) {
value = Math.max(value, 1);
if (this._maxValue === value)
return;
this._maxValue = value;
this._overdriveStart = Math.min(this._overdriveStart, this._maxValue);
this.notify('maximum-value');
this.queue_repaint();
}
get overdriveStart() {
return this._overdriveStart;
}
set overdriveStart(value) {
if (this._overdriveStart === value)
return;
if (value > this._maxValue) {
throw new Error(`Tried to set overdrive value to ${value}, ` +
`which is a number greater than the maximum allowed value ${this._maxValue}`);
}
this._overdriveStart = value;
this.notify('overdrive-start');
this.queue_repaint();
}
vfunc_style_changed() {
const themeNode = this.get_theme_node();
this._barLevelHeight = themeNode.get_length('-barlevel-height');
this._overdriveSeparatorWidth =
themeNode.get_length('-barlevel-overdrive-separator-width');
this._barLevelColor = themeNode.get_color('-barlevel-background-color');
this._barLevelActiveColor = themeNode.get_color('-barlevel-active-background-color');
this._barLevelOverdriveColor = themeNode.get_color('-barlevel-overdrive-color');
super.vfunc_style_changed();
}
vfunc_repaint() {
let cr = this.get_context();
let themeNode = this.get_theme_node();
let [width, height] = this.get_surface_size();
const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
const barLevelBorderRadius = Math.min(width, this._barLevelHeight) / 2;
let fgColor = themeNode.get_foreground_color();
const TAU = Math.PI * 2;
let endX = 0;
if (this._maxValue > 0) {
let progress = this._value / this._maxValue;
if (rtl)
progress = 1 - progress;
endX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * progress;
}
let overdriveRatio = this._overdriveStart / this._maxValue;
if (rtl)
overdriveRatio = 1 - overdriveRatio;
let overdriveSeparatorX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * overdriveRatio;
let overdriveActive = this._overdriveStart !== this._maxValue;
const overdriveSeparatorWidth = overdriveActive
? this._overdriveSeparatorWidth : 0;
let xcArcStart = barLevelBorderRadius;
let xcArcEnd = width - xcArcStart;
if (rtl)
[xcArcStart, xcArcEnd] = [xcArcEnd, xcArcStart];
/* background bar */
if (!rtl)
cr.arc(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
else
cr.arcNegative(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
cr.lineTo(endX, (height + this._barLevelHeight) / 2);
cr.lineTo(endX, (height - this._barLevelHeight) / 2);
cr.lineTo(xcArcEnd, (height - this._barLevelHeight) / 2);
cr.setSourceColor(this._barLevelColor);
cr.fillPreserve();
cr.fill();
/* normal progress bar */
let x = 0;
if (!rtl) {
x = Math.min(endX, overdriveSeparatorX - overdriveSeparatorWidth / 2);
cr.arc(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4));
} else {
x = Math.max(endX, overdriveSeparatorX + overdriveSeparatorWidth / 2);
cr.arcNegative(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4));
}
cr.lineTo(x, (height - this._barLevelHeight) / 2);
cr.lineTo(x, (height + this._barLevelHeight) / 2);
cr.lineTo(xcArcStart, (height + this._barLevelHeight) / 2);
if (this._value > 0)
cr.setSourceColor(this._barLevelActiveColor);
cr.fillPreserve();
cr.fill();
/* overdrive progress barLevel */
if (!rtl)
x = Math.min(endX, overdriveSeparatorX) + overdriveSeparatorWidth / 2;
else
x = Math.max(endX, overdriveSeparatorX) - overdriveSeparatorWidth / 2;
if (this._value > this._overdriveStart) {
cr.moveTo(x, (height - this._barLevelHeight) / 2);
cr.lineTo(endX, (height - this._barLevelHeight) / 2);
cr.lineTo(endX, (height + this._barLevelHeight) / 2);
cr.lineTo(x, (height + this._barLevelHeight) / 2);
cr.lineTo(x, (height - this._barLevelHeight) / 2);
cr.setSourceColor(this._barLevelOverdriveColor);
cr.fillPreserve();
cr.fill();
}
/* end progress bar arc */
if (this._value > 0) {
if (this._value <= this._overdriveStart)
cr.setSourceColor(this._barLevelActiveColor);
else
cr.setSourceColor(this._barLevelOverdriveColor);
if (!rtl) {
cr.arc(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
cr.lineTo(Math.floor(endX), (height + this._barLevelHeight) / 2);
cr.lineTo(Math.floor(endX), (height - this._barLevelHeight) / 2);
} else {
cr.arcNegative(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
cr.lineTo(Math.ceil(endX), (height + this._barLevelHeight) / 2);
cr.lineTo(Math.ceil(endX), (height - this._barLevelHeight) / 2);
}
cr.lineTo(endX, (height - this._barLevelHeight) / 2);
cr.fillPreserve();
}
/* draw overdrive separator */
if (overdriveActive) {
cr.moveTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - this._barLevelHeight) / 2);
cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height - this._barLevelHeight) / 2);
cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height + this._barLevelHeight) / 2);
cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height + this._barLevelHeight) / 2);
cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - this._barLevelHeight) / 2);
if (this._value <= this._overdriveStart)
cr.setSourceColor(fgColor);
else
cr.setSourceColor(this._barLevelColor);
cr.fill();
}
cr.$dispose();
}
vfunc_get_preferred_height(_forWidth) {
const themeNode = this.get_theme_node();
const height = this._getPreferredHeight();
return themeNode.adjust_preferred_height(height, height);
}
vfunc_get_preferred_width(_forHeight) {
const themeNode = this.get_theme_node();
const width = this._getPreferredWidth();
return themeNode.adjust_preferred_width(width, width);
}
_getPreferredHeight() {
return this._barLevelHeight;
}
_getPreferredWidth() {
return this._overdriveSeparatorWidth;
}
_getCurrentValue() {
return this._value;
}
_getOverdriveStart() {
return this._overdriveStart;
}
_getMinimumValue() {
return 0;
}
_getMaximumValue() {
return this._maxValue;
}
_setCurrentValue(_actor, value) {
this._value = value;
}
_valueChanged() {
this._customAccessible.notify('accessible-value');
}
});

662
js/ui/boxpointer.js Normal file
View file

@ -0,0 +1,662 @@
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as Main from './main.js';
export const PopupAnimation = {
NONE: 0,
SLIDE: 1 << 0,
FADE: 1 << 1,
FULL: ~0,
};
const POPUP_ANIMATION_TIME = 150;
/**
* BoxPointer:
*
* An actor which displays a triangle "arrow" pointing to a given
* side. The .bin property is a container in which content can be
* placed. The arrow position may be controlled via
* setArrowOrigin(). The arrow side might be temporarily flipped
* depending on the box size and source position to keep the box
* totally inside the monitor workarea if possible.
*
*/
export const BoxPointer = GObject.registerClass({
Signals: {'arrow-side-changed': {}},
}, class BoxPointer extends St.Widget {
/**
* @param {*} arrowSide side to draw the arrow on
* @param {*} binProperties Properties to set on contained bin
*/
_init(arrowSide, binProperties) {
super._init();
this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
this._arrowSide = arrowSide;
this._userArrowSide = arrowSide;
this._arrowOrigin = 0;
this._arrowActor = null;
this.bin = new St.Bin(binProperties);
this.add_child(this.bin);
this._border = new St.DrawingArea();
this._border.connect('repaint', this._drawBorder.bind(this));
this.add_child(this._border);
this.set_child_above_sibling(this.bin, this._border);
this._sourceAlignment = 0.5;
this._muteKeys = true;
this._muteInput = true;
this.connect('notify::visible', () => {
if (this.visible)
global.compositor.disable_unredirect();
else
global.compositor.enable_unredirect();
});
}
vfunc_captured_event(event) {
if (event.type() === Clutter.EventType.ENTER ||
event.type() === Clutter.EventType.LEAVE)
return Clutter.EVENT_PROPAGATE;
let mute = event.type() === Clutter.EventType.KEY_PRESS ||
event.type() === Clutter.EventType.KEY_RELEASE
? this._muteKeys : this._muteInput;
if (mute)
return Clutter.EVENT_STOP;
return Clutter.EVENT_PROPAGATE;
}
get arrowSide() {
return this._arrowSide;
}
open(animate, onComplete) {
let themeNode = this.get_theme_node();
let rise = themeNode.get_length('-arrow-rise');
let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0;
if (animate & PopupAnimation.FADE)
this.opacity = 0;
else
this.opacity = 255;
this._muteKeys = false;
this.show();
if (animate & PopupAnimation.SLIDE) {
switch (this._arrowSide) {
case St.Side.TOP:
this.translation_y = -rise;
break;
case St.Side.BOTTOM:
this.translation_y = rise;
break;
case St.Side.LEFT:
this.translation_x = -rise;
break;
case St.Side.RIGHT:
this.translation_x = rise;
break;
}
}
this.ease({
opacity: 255,
translation_x: 0,
translation_y: 0,
duration: animationTime,
mode: Clutter.AnimationMode.LINEAR,
onComplete: () => {
this._muteInput = false;
if (onComplete)
onComplete();
},
});
}
close(animate, onComplete) {
if (!this.visible)
return;
let translationX = 0;
let translationY = 0;
let themeNode = this.get_theme_node();
let rise = themeNode.get_length('-arrow-rise');
let fade = animate & PopupAnimation.FADE;
let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0;
if (animate & PopupAnimation.SLIDE) {
switch (this._arrowSide) {
case St.Side.TOP:
translationY = rise;
break;
case St.Side.BOTTOM:
translationY = -rise;
break;
case St.Side.LEFT:
translationX = rise;
break;
case St.Side.RIGHT:
translationX = -rise;
break;
}
}
this._muteInput = true;
this._muteKeys = true;
this.remove_all_transitions();
this.ease({
opacity: fade ? 0 : 255,
translation_x: translationX,
translation_y: translationY,
duration: animationTime,
mode: Clutter.AnimationMode.LINEAR,
onComplete: () => {
this.hide();
this.opacity = 0;
this.translation_x = 0;
this.translation_y = 0;
if (onComplete)
onComplete();
},
});
}
_adjustAllocationForArrow(isWidth, minSize, natSize) {
let themeNode = this.get_theme_node();
let borderWidth = themeNode.get_length('-arrow-border-width');
minSize += borderWidth * 2;
natSize += borderWidth * 2;
if ((!isWidth && (this._arrowSide === St.Side.TOP || this._arrowSide === St.Side.BOTTOM)) ||
(isWidth && (this._arrowSide === St.Side.LEFT || this._arrowSide === St.Side.RIGHT))) {
let rise = themeNode.get_length('-arrow-rise');
minSize += rise;
natSize += rise;
}
return [minSize, natSize];
}
vfunc_get_preferred_width(forHeight) {
let themeNode = this.get_theme_node();
forHeight = themeNode.adjust_for_height(forHeight);
let width = this.bin.get_preferred_width(forHeight);
width = this._adjustAllocationForArrow(true, ...width);
return themeNode.adjust_preferred_width(...width);
}
vfunc_get_preferred_height(forWidth) {
let themeNode = this.get_theme_node();
let borderWidth = themeNode.get_length('-arrow-border-width');
forWidth = themeNode.adjust_for_width(forWidth);
let height = this.bin.get_preferred_height(forWidth - 2 * borderWidth);
height = this._adjustAllocationForArrow(false, ...height);
return themeNode.adjust_preferred_height(...height);
}
vfunc_allocate(box) {
if (this._sourceActor && this._sourceActor.mapped) {
this._reposition(box);
this._updateFlip(box);
}
this.set_allocation(box);
let themeNode = this.get_theme_node();
let borderWidth = themeNode.get_length('-arrow-border-width');
let rise = themeNode.get_length('-arrow-rise');
let childBox = new Clutter.ActorBox();
let [availWidth, availHeight] = themeNode.get_content_box(box).get_size();
childBox.x1 = 0;
childBox.y1 = 0;
childBox.x2 = availWidth;
childBox.y2 = availHeight;
this._border.allocate(childBox);
childBox.x1 = borderWidth;
childBox.y1 = borderWidth;
childBox.x2 = availWidth - borderWidth;
childBox.y2 = availHeight - borderWidth;
switch (this._arrowSide) {
case St.Side.TOP:
childBox.y1 += rise;
break;
case St.Side.BOTTOM:
childBox.y2 -= rise;
break;
case St.Side.LEFT:
childBox.x1 += rise;
break;
case St.Side.RIGHT:
childBox.x2 -= rise;
break;
}
this.bin.allocate(childBox);
}
_drawBorder(area) {
let themeNode = this.get_theme_node();
if (this._arrowActor) {
let [sourceX, sourceY] = this._arrowActor.get_transformed_position();
let [sourceWidth, sourceHeight] = this._arrowActor.get_transformed_size();
let [absX, absY] = this.get_transformed_position();
if (this._arrowSide === St.Side.TOP ||
this._arrowSide === St.Side.BOTTOM)
this._arrowOrigin = sourceX - absX + sourceWidth / 2;
else
this._arrowOrigin = sourceY - absY + sourceHeight / 2;
}
let borderWidth = themeNode.get_length('-arrow-border-width');
let base = themeNode.get_length('-arrow-base');
let rise = themeNode.get_length('-arrow-rise');
let borderRadius = themeNode.get_length('-arrow-border-radius');
let halfBorder = borderWidth / 2;
let halfBase = Math.floor(base / 2);
let [width, height] = area.get_surface_size();
let [boxWidth, boxHeight] = [width, height];
if (this._arrowSide === St.Side.TOP || this._arrowSide === St.Side.BOTTOM)
boxHeight -= rise;
else
boxWidth -= rise;
let cr = area.get_context();
// Translate so that box goes from 0,0 to boxWidth,boxHeight,
// with the arrow poking out of that
if (this._arrowSide === St.Side.TOP)
cr.translate(0, rise);
else if (this._arrowSide === St.Side.LEFT)
cr.translate(rise, 0);
let [x1, y1] = [halfBorder, halfBorder];
let [x2, y2] = [boxWidth - halfBorder, boxHeight - halfBorder];
let skipTopLeft = false;
let skipTopRight = false;
let skipBottomLeft = false;
let skipBottomRight = false;
if (rise) {
switch (this._arrowSide) {
case St.Side.TOP:
if (this._arrowOrigin === x1)
skipTopLeft = true;
else if (this._arrowOrigin === x2)
skipTopRight = true;
break;
case St.Side.RIGHT:
if (this._arrowOrigin === y1)
skipTopRight = true;
else if (this._arrowOrigin === y2)
skipBottomRight = true;
break;
case St.Side.BOTTOM:
if (this._arrowOrigin === x1)
skipBottomLeft = true;
else if (this._arrowOrigin === x2)
skipBottomRight = true;
break;
case St.Side.LEFT:
if (this._arrowOrigin === y1)
skipTopLeft = true;
else if (this._arrowOrigin === y2)
skipBottomLeft = true;
break;
}
}
cr.moveTo(x1 + borderRadius, y1);
if (this._arrowSide === St.Side.TOP && rise) {
if (skipTopLeft) {
cr.moveTo(x1, y2 - borderRadius);
cr.lineTo(x1, y1 - rise);
cr.lineTo(x1 + halfBase, y1);
} else if (skipTopRight) {
cr.lineTo(x2 - halfBase, y1);
cr.lineTo(x2, y1 - rise);
cr.lineTo(x2, y1 + borderRadius);
} else {
cr.lineTo(this._arrowOrigin - halfBase, y1);
cr.lineTo(this._arrowOrigin, y1 - rise);
cr.lineTo(this._arrowOrigin + halfBase, y1);
}
}
if (!skipTopRight) {
cr.lineTo(x2 - borderRadius, y1);
cr.arc(
x2 - borderRadius, y1 + borderRadius, borderRadius,
3 * Math.PI / 2, Math.PI * 2);
}
if (this._arrowSide === St.Side.RIGHT && rise) {
if (skipTopRight) {
cr.lineTo(x2 + rise, y1);
cr.lineTo(x2 + rise, y1 + halfBase);
} else if (skipBottomRight) {
cr.lineTo(x2, y2 - halfBase);
cr.lineTo(x2 + rise, y2);
cr.lineTo(x2 - borderRadius, y2);
} else {
cr.lineTo(x2, this._arrowOrigin - halfBase);
cr.lineTo(x2 + rise, this._arrowOrigin);
cr.lineTo(x2, this._arrowOrigin + halfBase);
}
}
if (!skipBottomRight) {
cr.lineTo(x2, y2 - borderRadius);
cr.arc(
x2 - borderRadius, y2 - borderRadius, borderRadius,
0, Math.PI / 2);
}
if (this._arrowSide === St.Side.BOTTOM && rise) {
if (skipBottomLeft) {
cr.lineTo(x1 + halfBase, y2);
cr.lineTo(x1, y2 + rise);
cr.lineTo(x1, y2 - borderRadius);
} else if (skipBottomRight) {
cr.lineTo(x2, y2 + rise);
cr.lineTo(x2 - halfBase, y2);
} else {
cr.lineTo(this._arrowOrigin + halfBase, y2);
cr.lineTo(this._arrowOrigin, y2 + rise);
cr.lineTo(this._arrowOrigin - halfBase, y2);
}
}
if (!skipBottomLeft) {
cr.lineTo(x1 + borderRadius, y2);
cr.arc(
x1 + borderRadius, y2 - borderRadius, borderRadius,
Math.PI / 2, Math.PI);
}
if (this._arrowSide === St.Side.LEFT && rise) {
if (skipTopLeft) {
cr.lineTo(x1, y1 + halfBase);
cr.lineTo(x1 - rise, y1);
cr.lineTo(x1 + borderRadius, y1);
} else if (skipBottomLeft) {
cr.lineTo(x1 - rise, y2);
cr.lineTo(x1 - rise, y2 - halfBase);
} else {
cr.lineTo(x1, this._arrowOrigin + halfBase);
cr.lineTo(x1 - rise, this._arrowOrigin);
cr.lineTo(x1, this._arrowOrigin - halfBase);
}
}
if (!skipTopLeft) {
cr.lineTo(x1, y1 + borderRadius);
cr.arc(
x1 + borderRadius, y1 + borderRadius, borderRadius,
Math.PI, 3 * Math.PI / 2);
}
const [hasColor, bgColor] =
themeNode.lookup_color('-arrow-background-color', false);
if (hasColor) {
cr.setSourceColor(bgColor);
cr.fillPreserve();
}
if (borderWidth > 0) {
let borderColor = themeNode.get_color('-arrow-border-color');
cr.setSourceColor(borderColor);
cr.setLineWidth(borderWidth);
cr.stroke();
}
cr.$dispose();
}
setPosition(sourceActor, alignment) {
if (!this._sourceActor || sourceActor !== this._sourceActor) {
this._sourceActor?.disconnectObject(this);
this._sourceActor = sourceActor;
this._sourceActor?.connectObject('destroy',
() => (this._sourceActor = null), this);
}
this._arrowAlignment = Math.clamp(alignment, 0.0, 1.0);
this.queue_relayout();
}
setSourceAlignment(alignment) {
this._sourceAlignment = Math.clamp(alignment, 0.0, 1.0);
if (!this._sourceActor)
return;
this.setPosition(this._sourceActor, this._arrowAlignment);
}
_reposition(allocationBox) {
let sourceActor = this._sourceActor;
let alignment = this._arrowAlignment;
let monitorIndex = Main.layoutManager.findIndexForActor(sourceActor);
this._sourceExtents = sourceActor.get_transformed_extents();
this._workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
// Position correctly relative to the sourceActor
const sourceAllocation = sourceActor.get_allocation_box();
const sourceContentBox = sourceActor instanceof St.Widget
? sourceActor.get_theme_node().get_content_box(sourceAllocation)
: new Clutter.ActorBox({
x2: sourceAllocation.get_width(),
y2: sourceAllocation.get_height(),
});
let sourceTopLeft = this._sourceExtents.get_top_left();
let sourceBottomRight = this._sourceExtents.get_bottom_right();
let sourceCenterX = sourceTopLeft.x + sourceContentBox.x1 + (sourceContentBox.x2 - sourceContentBox.x1) * this._sourceAlignment;
let sourceCenterY = sourceTopLeft.y + sourceContentBox.y1 + (sourceContentBox.y2 - sourceContentBox.y1) * this._sourceAlignment;
let [, , natWidth, natHeight] = this.get_preferred_size();
// We also want to keep it onscreen, and separated from the
// edge by the same distance as the main part of the box is
// separated from its sourceActor
let workarea = this._workArea;
let themeNode = this.get_theme_node();
let borderWidth = themeNode.get_length('-arrow-border-width');
let arrowBase = themeNode.get_length('-arrow-base');
let borderRadius = themeNode.get_length('-arrow-border-radius');
let margin = 4 * borderRadius + borderWidth + arrowBase;
let gap = themeNode.get_length('-boxpointer-gap');
let padding = themeNode.get_length('-arrow-rise');
let resX, resY;
switch (this._arrowSide) {
case St.Side.TOP:
resY = sourceBottomRight.y + gap;
break;
case St.Side.BOTTOM:
resY = sourceTopLeft.y - natHeight - gap;
break;
case St.Side.LEFT:
resX = sourceBottomRight.x + gap;
break;
case St.Side.RIGHT:
resX = sourceTopLeft.x - natWidth - gap;
break;
}
// Now align and position the pointing axis, making sure it fits on
// screen. If the arrowOrigin is so close to the edge that the arrow
// will not be isosceles, we try to compensate as follows:
// - We skip the rounded corner and settle for a right angled arrow
// as shown below. See _drawBorder for further details.
// |\_____
// |
// |
// - If the arrow was going to be acute angled, we move the position
// of the box to maintain the arrow's accuracy.
let arrowOrigin;
let halfBase = Math.floor(arrowBase / 2);
let halfBorder = borderWidth / 2;
let halfMargin = margin / 2;
let [x1, y1] = [halfBorder, halfBorder];
let [x2, y2] = [natWidth - halfBorder, natHeight - halfBorder];
switch (this._arrowSide) {
case St.Side.TOP:
case St.Side.BOTTOM:
if (this.text_direction === Clutter.TextDirection.RTL)
alignment = 1.0 - alignment;
resX = sourceCenterX - (halfMargin + (natWidth - margin) * alignment);
resX = Math.max(resX, workarea.x + padding);
resX = Math.min(resX, workarea.x + workarea.width - (padding + natWidth));
arrowOrigin = sourceCenterX - resX;
if (arrowOrigin <= (x1 + (borderRadius + halfBase))) {
if (arrowOrigin > x1)
resX += arrowOrigin - x1;
arrowOrigin = x1;
} else if (arrowOrigin >= (x2 - (borderRadius + halfBase))) {
if (arrowOrigin < x2)
resX -= x2 - arrowOrigin;
arrowOrigin = x2;
}
break;
case St.Side.LEFT:
case St.Side.RIGHT:
resY = sourceCenterY - (halfMargin + (natHeight - margin) * alignment);
resY = Math.max(resY, workarea.y + padding);
resY = Math.min(resY, workarea.y + workarea.height - (padding + natHeight));
arrowOrigin = sourceCenterY - resY;
if (arrowOrigin <= (y1 + (borderRadius + halfBase))) {
if (arrowOrigin > y1)
resY += arrowOrigin - y1;
arrowOrigin = y1;
} else if (arrowOrigin >= (y2 - (borderRadius + halfBase))) {
if (arrowOrigin < y2)
resY -= y2 - arrowOrigin;
arrowOrigin = y2;
}
break;
}
this.setArrowOrigin(arrowOrigin);
let parent = this.get_parent();
let success, x, y;
while (!success) {
[success, x, y] = parent.transform_stage_point(resX, resY);
parent = parent.get_parent();
}
// Actually set the position
allocationBox.set_origin(Math.floor(x), Math.floor(y));
}
// @origin: Coordinate specifying middle of the arrow, along
// the Y axis for St.Side.LEFT, St.Side.RIGHT from the top and X axis from
// the left for St.Side.TOP and St.Side.BOTTOM.
setArrowOrigin(origin) {
if (this._arrowOrigin !== origin) {
this._arrowOrigin = origin;
this._border.queue_repaint();
}
}
// @actor: an actor relative to which the arrow is positioned.
// Differently from setPosition, this will not move the boxpointer itself,
// on the arrow
setArrowActor(actor) {
if (this._arrowActor !== actor) {
this._arrowActor = actor;
this._border.queue_repaint();
}
}
_calculateArrowSide(arrowSide) {
let sourceTopLeft = this._sourceExtents.get_top_left();
let sourceBottomRight = this._sourceExtents.get_bottom_right();
let [, , boxWidth, boxHeight] = this.get_preferred_size();
let workarea = this._workArea;
switch (arrowSide) {
case St.Side.TOP:
if (sourceBottomRight.y + boxHeight > workarea.y + workarea.height &&
boxHeight < sourceTopLeft.y - workarea.y)
return St.Side.BOTTOM;
break;
case St.Side.BOTTOM:
if (sourceTopLeft.y - boxHeight < workarea.y &&
boxHeight < workarea.y + workarea.height - sourceBottomRight.y)
return St.Side.TOP;
break;
case St.Side.LEFT:
if (sourceBottomRight.x + boxWidth > workarea.x + workarea.width &&
boxWidth < sourceTopLeft.x - workarea.x)
return St.Side.RIGHT;
break;
case St.Side.RIGHT:
if (sourceTopLeft.x - boxWidth < workarea.x &&
boxWidth < workarea.x + workarea.width - sourceBottomRight.x)
return St.Side.LEFT;
break;
}
return arrowSide;
}
_updateFlip(allocationBox) {
let arrowSide = this._calculateArrowSide(this._userArrowSide);
if (this._arrowSide !== arrowSide) {
this._arrowSide = arrowSide;
this._reposition(allocationBox);
this.emit('arrow-side-changed');
}
}
updateArrowSide(side) {
this._arrowSide = side;
this._border.queue_repaint();
this.emit('arrow-side-changed');
}
getPadding(side) {
return this.bin.get_theme_node().get_padding(side);
}
getArrowHeight() {
return this.get_theme_node().get_length('-arrow-rise');
}
});

904
js/ui/calendar.js Normal file
View file

@ -0,0 +1,904 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as MessageList from './messageList.js';
import * as PopupMenu from './popupMenu.js';
import {ensureActorVisibleInScrollView} from '../misc/animationUtils.js';
import {formatDateWithCFormatString} from '../misc/dateUtils.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const SHOW_WEEKDATE_KEY = 'show-weekdate';
const NC_ = (context, str) => `${context}\u0004${str}`;
function sameYear(dateA, dateB) {
return dateA.getYear() === dateB.getYear();
}
function sameMonth(dateA, dateB) {
return sameYear(dateA, dateB) && (dateA.getMonth() === dateB.getMonth());
}
function sameDay(dateA, dateB) {
return sameMonth(dateA, dateB) && (dateA.getDate() === dateB.getDate());
}
function _isWorkDay(date) {
/* Translators: Enter 0-6 (Sunday-Saturday) for non-work days. Examples: "0" (Sunday) "6" (Saturday) "06" (Sunday and Saturday). */
let days = C_('calendar-no-work', '06');
return !days.includes(date.getDay().toString());
}
function _getBeginningOfDay(date) {
let ret = new Date(date.getTime());
ret.setHours(0);
ret.setMinutes(0);
ret.setSeconds(0);
ret.setMilliseconds(0);
return ret;
}
function _getEndOfDay(date) {
const ret = _getBeginningOfDay(date);
ret.setDate(ret.getDate() + 1);
return ret;
}
function _getCalendarDayAbbreviation(dayNumber) {
let abbreviations = [
/* Translators: Calendar grid abbreviation for Sunday.
*
* NOTE: These grid abbreviations are always shown together
* and in order, e.g. "S M T W T F S".
*/
NC_('grid sunday', 'S'),
/* Translators: Calendar grid abbreviation for Monday */
NC_('grid monday', 'M'),
/* Translators: Calendar grid abbreviation for Tuesday */
NC_('grid tuesday', 'T'),
/* Translators: Calendar grid abbreviation for Wednesday */
NC_('grid wednesday', 'W'),
/* Translators: Calendar grid abbreviation for Thursday */
NC_('grid thursday', 'T'),
/* Translators: Calendar grid abbreviation for Friday */
NC_('grid friday', 'F'),
/* Translators: Calendar grid abbreviation for Saturday */
NC_('grid saturday', 'S'),
];
return Shell.util_translate_time_string(abbreviations[dayNumber]);
}
// Abstraction for an appointment/event in a calendar
class CalendarEvent {
constructor(id, date, end, summary) {
this.id = id;
this.date = date;
this.end = end;
this.summary = summary;
}
}
// Interface for appointments/events - e.g. the contents of a calendar
//
export const EventSourceBase = GObject.registerClass({
GTypeFlags: GObject.TypeFlags.ABSTRACT,
Properties: {
'has-calendars': GObject.ParamSpec.boolean(
'has-calendars', null, null,
GObject.ParamFlags.READABLE,
false),
'is-loading': GObject.ParamSpec.boolean(
'is-loading', null, null,
GObject.ParamFlags.READABLE,
false),
},
Signals: {'changed': {}},
}, class EventSourceBase extends GObject.Object {
/**
* @returns {boolean}
*/
get isLoading() {
throw new GObject.NotImplementedError(`isLoading in ${this.constructor.name}`);
}
/**
* @returns {boolean}
*/
get hasCalendars() {
throw new GObject.NotImplementedError(`hasCalendars in ${this.constructor.name}`);
}
destroy() {
}
requestRange(_begin, _end) {
throw new GObject.NotImplementedError(`requestRange in ${this.constructor.name}`);
}
getEvents(_begin, _end) {
throw new GObject.NotImplementedError(`getEvents in ${this.constructor.name}`);
}
/**
* @param {Date} _day
* @returns {boolean}
*/
hasEvents(_day) {
throw new GObject.NotImplementedError(`hasEvents in ${this.constructor.name}`);
}
});
export const EmptyEventSource = GObject.registerClass(
class EmptyEventSource extends EventSourceBase {
get isLoading() {
return false;
}
get hasCalendars() {
return false;
}
requestRange(_begin, _end) {
}
getEvents(_begin, _end) {
let result = [];
return result;
}
hasEvents(_day) {
return false;
}
});
const CalendarServerIface = loadInterfaceXML('org.gnome.Shell.CalendarServer');
const CalendarServerInfo = Gio.DBusInterfaceInfo.new_for_xml(CalendarServerIface);
function CalendarServer() {
return new Gio.DBusProxy({
g_connection: Gio.DBus.session,
g_interface_name: CalendarServerInfo.name,
g_interface_info: CalendarServerInfo,
g_name: 'org.gnome.Shell.CalendarServer',
g_object_path: '/org/gnome/Shell/CalendarServer',
});
}
function _datesEqual(a, b) {
if (a < b)
return false;
else if (a > b)
return false;
return true;
}
/**
* Checks whether an event overlaps a given interval
*
* @param {Date} e0 Beginning of the event
* @param {Date} e1 End of the event
* @param {Date} i0 Beginning of the interval
* @param {Date} i1 End of the interval
* @returns {boolean} Whether there was an overlap
*/
function _eventOverlapsInterval(e0, e1, i0, i1) {
// This also ensures zero-length events are included
if (e0 >= i0 && e1 < i1)
return true;
if (e1 <= i0)
return false;
if (i1 <= e0)
return false;
return true;
}
// an implementation that reads data from a session bus service
export const DBusEventSource = GObject.registerClass(
class DBusEventSource extends EventSourceBase {
_init() {
super._init();
this._resetCache();
this._isLoading = false;
this._initialized = false;
this._dbusProxy = new CalendarServer();
this._initProxy();
}
async _initProxy() {
let loaded = false;
try {
await this._dbusProxy.init_async(GLib.PRIORITY_DEFAULT, null);
loaded = true;
} catch (e) {
// Ignore timeouts and install signals as normal, because with high
// probability the service will appear later on, and we will get a
// NameOwnerChanged which will finish loading
//
// (But still _initialized to false, because the proxy does not know
// about the HasCalendars property and would cause an exception trying
// to read it)
if (!e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT)) {
log(`Error loading calendars: ${e.message}`);
return;
}
}
this._dbusProxy.connectSignal('EventsAddedOrUpdated',
this._onEventsAddedOrUpdated.bind(this));
this._dbusProxy.connectSignal('EventsRemoved',
this._onEventsRemoved.bind(this));
this._dbusProxy.connectSignal('ClientDisappeared',
this._onClientDisappeared.bind(this));
this._dbusProxy.connect('notify::g-name-owner', () => {
if (this._dbusProxy.g_name_owner)
this._onNameAppeared();
else
this._onNameVanished();
});
this._dbusProxy.connect('g-properties-changed', () => {
this.notify('has-calendars');
});
this._initialized = loaded;
if (loaded) {
this.notify('has-calendars');
this._onNameAppeared();
}
}
destroy() {
this._dbusProxy.run_dispose();
}
get hasCalendars() {
if (this._initialized)
return this._dbusProxy.HasCalendars;
else
return false;
}
get isLoading() {
return this._isLoading;
}
_resetCache() {
this._events = new Map();
this._lastRequestBegin = null;
this._lastRequestEnd = null;
}
_removeMatching(uidPrefix) {
let changed = false;
for (const id of this._events.keys()) {
if (id.startsWith(uidPrefix))
changed = this._events.delete(id) || changed;
}
return changed;
}
_onNameAppeared() {
this._initialized = true;
this._resetCache();
this._loadEvents(true);
}
_onNameVanished() {
this._resetCache();
this.emit('changed');
}
_onEventsAddedOrUpdated(dbusProxy, nameOwner, argArray) {
const [appointments = []] = argArray;
let changed = false;
const handledRemovals = new Set();
for (let n = 0; n < appointments.length; n++) {
const [id, summary, startTime, endTime] = appointments[n];
const date = new Date(startTime * 1000);
const end = new Date(endTime * 1000);
let event = new CalendarEvent(id, date, end, summary);
/* It's a recurring event */
if (!id.endsWith('\n')) {
const parentId = id.substring(0, id.lastIndexOf('\n') + 1);
if (!handledRemovals.has(parentId)) {
handledRemovals.add(parentId);
this._removeMatching(parentId);
}
}
this._events.set(event.id, event);
changed = true;
}
if (changed)
this.emit('changed');
}
_onEventsRemoved(dbusProxy, nameOwner, argArray) {
const [ids = []] = argArray;
let changed = false;
for (const id of ids)
changed = this._removeMatching(id) || changed;
if (changed)
this.emit('changed');
}
_onClientDisappeared(dbusProxy, nameOwner, argArray) {
let [sourceUid = ''] = argArray;
sourceUid += '\n';
if (this._removeMatching(sourceUid))
this.emit('changed');
}
_loadEvents(forceReload) {
// Ignore while loading
if (!this._initialized)
return;
if (this._curRequestBegin && this._curRequestEnd) {
if (forceReload) {
this._events.clear();
this.emit('changed');
}
this._dbusProxy.SetTimeRangeAsync(
this._curRequestBegin.getTime() / 1000,
this._curRequestEnd.getTime() / 1000,
forceReload,
Gio.DBusCallFlags.NONE).catch(logError);
}
}
requestRange(begin, end) {
if (!(_datesEqual(begin, this._lastRequestBegin) && _datesEqual(end, this._lastRequestEnd))) {
this._lastRequestBegin = begin;
this._lastRequestEnd = end;
this._curRequestBegin = begin;
this._curRequestEnd = end;
this._loadEvents(true);
}
}
*_getFilteredEvents(begin, end) {
for (const event of this._events.values()) {
if (_eventOverlapsInterval(event.date, event.end, begin, end))
yield event;
}
}
getEvents(begin, end) {
let result = [...this._getFilteredEvents(begin, end)];
result.sort((event1, event2) => {
// sort events by end time on ending day
let d1 = event1.date < begin && event1.end <= end ? event1.end : event1.date;
let d2 = event2.date < begin && event2.end <= end ? event2.end : event2.date;
return d1.getTime() - d2.getTime();
});
return result;
}
hasEvents(day) {
let dayBegin = _getBeginningOfDay(day);
let dayEnd = _getEndOfDay(day);
const {done} = this._getFilteredEvents(dayBegin, dayEnd).next();
return !done;
}
});
export const Calendar = GObject.registerClass({
Signals: {'selected-date-changed': {param_types: [GLib.DateTime.$gtype]}},
}, class Calendar extends St.Widget {
_init() {
this._weekStart = Shell.util_get_week_start();
this._settings = new Gio.Settings({schema_id: 'org.gnome.desktop.calendar'});
this._settings.connect(`changed::${SHOW_WEEKDATE_KEY}`, this._onSettingsChange.bind(this));
this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY);
/**
* Translators: The header displaying just the month name
* standalone, when this is a month of the current year.
* "%OB" is the new format specifier introduced in glibc 2.27,
* in most cases you should not change it.
*/
this._headerFormatWithoutYear = _('%OB');
/**
* Translators: The header displaying the month name and the year
* number, when this is a month of a different year. You can
* reorder the format specifiers or add other modifications
* according to the requirements of your language.
* "%OB" is the new format specifier introduced in glibc 2.27,
* in most cases you should not use the old "%B" here unless you
* absolutely know what you are doing.
*/
this._headerFormat = _('%OB %Y');
// Start off with the current date
this._selectedDate = new Date();
this._shouldDateGrabFocus = false;
super._init({
style_class: 'calendar',
layout_manager: new Clutter.GridLayout(),
reactive: true,
});
this._buildHeader();
}
setEventSource(eventSource) {
if (!(eventSource instanceof EventSourceBase))
throw new Error('Event source is not valid type');
this._eventSource = eventSource;
this._eventSource.connect('changed', () => {
this._rebuildCalendar();
this._update();
});
this._rebuildCalendar();
this._update();
}
// Sets the calendar to show a specific date
setDate(date) {
if (sameDay(date, this._selectedDate))
return;
this._selectedDate = date;
let datetime = GLib.DateTime.new_from_unix_local(
this._selectedDate.getTime() / 1000);
this.emit('selected-date-changed', datetime);
this._update();
}
updateTimeZone() {
// The calendar need to be rebuilt after a time zone update because
// the date might have changed.
this._rebuildCalendar();
this._update();
}
_buildHeader() {
let layout = this.layout_manager;
let offsetCols = this._useWeekdate ? 1 : 0;
this.destroy_all_children();
// Top line of the calendar '<| September 2009 |>'
this._topBox = new St.BoxLayout({style_class: 'calendar-month-header'});
layout.attach(this._topBox, 0, 0, offsetCols + 7, 1);
this._backButton = new St.Button({
style_class: 'calendar-change-month-back pager-button',
icon_name: 'pan-start-symbolic',
accessible_name: _('Previous month'),
can_focus: true,
});
this._topBox.add_child(this._backButton);
this._backButton.connect('clicked', this._onPrevMonthButtonClicked.bind(this));
this._monthLabel = new St.Label({
style_class: 'calendar-month-label',
can_focus: true,
x_align: Clutter.ActorAlign.CENTER,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this._topBox.add_child(this._monthLabel);
this._forwardButton = new St.Button({
style_class: 'calendar-change-month-forward pager-button',
icon_name: 'pan-end-symbolic',
accessible_name: _('Next month'),
can_focus: true,
});
this._topBox.add_child(this._forwardButton);
this._forwardButton.connect('clicked', this._onNextMonthButtonClicked.bind(this));
// Add weekday labels...
//
// We need to figure out the abbreviated localized names for the days of the week;
// we do this by just getting the next 7 days starting from right now and then putting
// them in the right cell in the table. It doesn't matter if we add them in order
let iter = new Date(this._selectedDate);
iter.setSeconds(0); // Leap second protection. Hah!
iter.setHours(12);
for (let i = 0; i < 7; i++) {
// Could use formatDateWithCFormatString(iter, '%a') but that normally gives three characters
// and we want, ideally, a single character for e.g. S M T W T F S
let customDayAbbrev = _getCalendarDayAbbreviation(iter.getDay());
let label = new St.Label({
style_class: 'calendar-day-heading',
text: customDayAbbrev,
can_focus: true,
});
label.accessible_name = formatDateWithCFormatString(iter, '%A');
let col;
if (this.get_text_direction() === Clutter.TextDirection.RTL)
col = 6 - (7 + iter.getDay() - this._weekStart) % 7;
else
col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7;
layout.attach(label, col, 1, 1, 1);
iter.setDate(iter.getDate() + 1);
}
// All the children after this are days, and get removed when we update the calendar
this._firstDayIndex = this.get_n_children();
}
vfunc_scroll_event(event) {
switch (event.get_scroll_direction()) {
case Clutter.ScrollDirection.UP:
case Clutter.ScrollDirection.LEFT:
this._onPrevMonthButtonClicked();
break;
case Clutter.ScrollDirection.DOWN:
case Clutter.ScrollDirection.RIGHT:
this._onNextMonthButtonClicked();
break;
}
return Clutter.EVENT_PROPAGATE;
}
_onPrevMonthButtonClicked() {
let newDate = new Date(this._selectedDate);
let oldMonth = newDate.getMonth();
if (oldMonth === 0) {
newDate.setMonth(11);
newDate.setFullYear(newDate.getFullYear() - 1);
if (newDate.getMonth() !== 11) {
let day = 32 - new Date(newDate.getFullYear() - 1, 11, 32).getDate();
newDate = new Date(newDate.getFullYear() - 1, 11, day);
}
} else {
newDate.setMonth(oldMonth - 1);
if (newDate.getMonth() !== oldMonth - 1) {
let day = 32 - new Date(newDate.getFullYear(), oldMonth - 1, 32).getDate();
newDate = new Date(newDate.getFullYear(), oldMonth - 1, day);
}
}
this._backButton.grab_key_focus();
this.setDate(newDate);
}
_onNextMonthButtonClicked() {
let newDate = new Date(this._selectedDate);
let oldMonth = newDate.getMonth();
if (oldMonth === 11) {
newDate.setMonth(0);
newDate.setFullYear(newDate.getFullYear() + 1);
if (newDate.getMonth() !== 0) {
let day = 32 - new Date(newDate.getFullYear() + 1, 0, 32).getDate();
newDate = new Date(newDate.getFullYear() + 1, 0, day);
}
} else {
newDate.setMonth(oldMonth + 1);
if (newDate.getMonth() !== oldMonth + 1) {
let day = 32 - new Date(newDate.getFullYear(), oldMonth + 1, 32).getDate();
newDate = new Date(newDate.getFullYear(), oldMonth + 1, day);
}
}
this._forwardButton.grab_key_focus();
this.setDate(newDate);
}
_onSettingsChange() {
this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY);
this._buildHeader();
this._rebuildCalendar();
this._update();
}
_rebuildCalendar() {
let now = new Date();
// Remove everything but the topBox and the weekday labels
let children = this.get_children();
for (let i = this._firstDayIndex; i < children.length; i++)
children[i].destroy();
this._buttons = [];
// Start at the beginning of the week before the start of the month
//
// We want to show always 6 weeks (to keep the calendar menu at the same
// height if there are no events), so we pad it according to the following
// policy:
//
// 1 - If a month has 6 weeks, we place no padding (example: Dec 2012)
// 2 - If a month has 5 weeks and it starts on week start, we pad one week
// before it (example: Apr 2012)
// 3 - If a month has 5 weeks and it starts on any other day, we pad one week
// after it (example: Nov 2012)
// 4 - If a month has 4 weeks, we pad one week before and one after it
// (example: Feb 2010)
//
// Actually computing the number of weeks is complex, but we know that the
// problematic categories (2 and 4) always start on week start, and that
// all months at the end have 6 weeks.
let beginDate = new Date(
this._selectedDate.getFullYear(), this._selectedDate.getMonth(), 1);
this._calendarBegin = new Date(beginDate);
this._markedAsToday = now;
let daysToWeekStart = (7 + beginDate.getDay() - this._weekStart) % 7;
let startsOnWeekStart = daysToWeekStart === 0;
let weekPadding = startsOnWeekStart ? 7 : 0;
beginDate.setDate(beginDate.getDate() - (weekPadding + daysToWeekStart));
let layout = this.layout_manager;
let iter = new Date(beginDate);
let row = 2;
// nRows here means 6 weeks + one header + one navbar
let nRows = 8;
while (row < nRows) {
let button = new St.Button({
// xgettext:no-javascript-format
label: formatDateWithCFormatString(iter, C_('date day number format', '%d')),
can_focus: true,
});
let rtl = button.get_text_direction() === Clutter.TextDirection.RTL;
if (this._eventSource instanceof EmptyEventSource)
button.reactive = false;
button._date = new Date(iter);
button.connect('clicked', () => {
this._shouldDateGrabFocus = true;
this.setDate(button._date);
this._shouldDateGrabFocus = false;
});
let hasEvents = this._eventSource.hasEvents(iter);
let styleClass = 'calendar-day';
if (_isWorkDay(iter))
styleClass += ' calendar-weekday';
else
styleClass += ' calendar-weekend';
// Hack used in lieu of border-collapse - see gnome-shell.css
if (row === 2)
styleClass = `calendar-day-top ${styleClass}`;
let leftMost = rtl
? iter.getDay() === (this._weekStart + 6) % 7
: iter.getDay() === this._weekStart;
if (leftMost)
styleClass = `calendar-day-left ${styleClass}`;
if (sameDay(now, iter))
styleClass += ' calendar-today';
else if (iter.getMonth() !== this._selectedDate.getMonth())
styleClass += ' calendar-other-month';
if (hasEvents)
styleClass += ' calendar-day-with-events';
button.style_class = styleClass;
let offsetCols = this._useWeekdate ? 1 : 0;
let col;
if (rtl)
col = 6 - (7 + iter.getDay() - this._weekStart) % 7;
else
col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7;
layout.attach(button, col, row, 1, 1);
this._buttons.push(button);
if (this._useWeekdate && iter.getDay() === 4) {
const label = new St.Label({
text: formatDateWithCFormatString(iter, '%V'),
style_class: 'calendar-week-number',
can_focus: true,
});
let weekFormat = Shell.util_translate_time_string(N_('Week %V'));
label.clutter_text.y_align = Clutter.ActorAlign.CENTER;
label.accessible_name = formatDateWithCFormatString(iter, weekFormat);
layout.attach(label, rtl ? 7 : 0, row, 1, 1);
}
iter.setDate(iter.getDate() + 1);
if (iter.getDay() === this._weekStart)
row++;
}
// Signal to the event source that we are interested in events
// only from this date range
this._eventSource.requestRange(beginDate, iter);
}
_update() {
let now = new Date();
if (sameYear(this._selectedDate, now))
this._monthLabel.text = formatDateWithCFormatString(this._selectedDate, this._headerFormatWithoutYear);
else
this._monthLabel.text = formatDateWithCFormatString(this._selectedDate, this._headerFormat);
if (!this._calendarBegin || !sameMonth(this._selectedDate, this._calendarBegin) || !sameDay(now, this._markedAsToday))
this._rebuildCalendar();
this._buttons.forEach(button => {
if (sameDay(button._date, this._selectedDate)) {
button.add_style_pseudo_class('selected');
if (this._shouldDateGrabFocus)
button.grab_key_focus();
} else {
button.remove_style_pseudo_class('selected');
}
});
}
});
const Placeholder = GObject.registerClass(
class Placeholder extends St.BoxLayout {
_init() {
super._init({
style_class: 'message-list-placeholder',
orientation: Clutter.Orientation.VERTICAL,
});
this._date = new Date();
this._icon = new St.Icon({icon_name: 'no-notifications-symbolic'});
this.add_child(this._icon);
this._label = new St.Label({text: _('No Notifications')});
this.add_child(this._label);
}
});
const DoNotDisturbSwitch = GObject.registerClass(
class DoNotDisturbSwitch extends PopupMenu.Switch {
_init() {
this._settings = new Gio.Settings({
schema_id: 'org.gnome.desktop.notifications',
});
super._init(this._settings.get_boolean('show-banners'));
this._settings.bind('show-banners',
this, 'state',
Gio.SettingsBindFlags.INVERT_BOOLEAN);
this.connect('destroy', () => {
Gio.Settings.unbind(this, 'state');
this._settings = null;
});
}
});
export const CalendarMessageList = GObject.registerClass(
class CalendarMessageList extends St.Widget {
constructor() {
super({
style_class: 'message-list',
layout_manager: new Clutter.BinLayout(),
x_expand: true,
y_expand: true,
});
this._placeholder = new Placeholder();
this.add_child(this._placeholder);
let box = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
x_expand: true,
y_expand: true,
});
this.add_child(box);
this._messageView = new MessageList.MessageView();
this._scrollView = new St.ScrollView({
overlay_scrollbars: true,
x_expand: true, y_expand: true,
child: this._messageView,
});
box.add_child(this._scrollView);
let hbox = new St.BoxLayout({style_class: 'message-list-controls'});
box.add_child(hbox);
const dndLabel = new St.Label({
text: _('Do Not Disturb'),
y_align: Clutter.ActorAlign.CENTER,
});
hbox.add_child(dndLabel);
this._dndSwitch = new DoNotDisturbSwitch();
this._dndButton = new St.Button({
style_class: 'dnd-button',
can_focus: true,
toggle_mode: true,
child: this._dndSwitch,
label_actor: dndLabel,
y_align: Clutter.ActorAlign.CENTER,
});
this._dndSwitch.bind_property('state',
this._dndButton, 'checked',
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE);
hbox.add_child(this._dndButton);
this._clearButton = new St.Button({
style_class: 'message-list-clear-button button',
label: _('Clear'),
can_focus: true,
x_expand: true,
x_align: Clutter.ActorAlign.END,
accessible_name: C_('action', 'Clear all notifications'),
});
this._clearButton.connect('clicked', () => {
this._messageView.clear();
});
hbox.add_child(this._clearButton);
this._placeholder.bind_property('visible',
this._clearButton, 'visible',
GObject.BindingFlags.INVERT_BOOLEAN);
this._messageView.connectObject(
'message-focused', (_s, messageActor) => {
ensureActorVisibleInScrollView(this._scrollView, messageActor);
}, this);
this._messageView.bind_property('empty',
this._placeholder, 'visible',
GObject.BindingFlags.SYNC_CREATE);
this._messageView.bind_property('can-clear',
this._clearButton, 'reactive',
GObject.BindingFlags.SYNC_CREATE);
}
maybeCollapseMessageGroupForEvent(event) {
if (!this._messageView.expandedGroup)
return Clutter.EVENT_PROPAGATE;
if (event.type() === Clutter.EventType.KEY_PRESS &&
event.get_key_symbol() === Clutter.KEY_Escape) {
this._messageView.collapse();
return Clutter.EVENT_STOP;
}
const targetActor = global.stage.get_event_actor(event);
const onScrollbar =
this._scrollView.contains(targetActor) &&
!this._messageView.contains(targetActor);
if ((event.type() === Clutter.EventType.BUTTON_PRESS ||
event.type() === Clutter.EventType.TOUCH_BEGIN) &&
!this._messageView.expandedGroup.contains(targetActor) &&
!onScrollbar)
this._messageView.collapse();
return Clutter.EVENT_PROPAGATE;
}
});

48
js/ui/checkBox.js Normal file
View file

@ -0,0 +1,48 @@
import Atk from 'gi://Atk';
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import St from 'gi://St';
export const CheckBox = GObject.registerClass(
class CheckBox extends St.Button {
_init(label) {
let container = new St.BoxLayout({
x_expand: true,
y_expand: true,
});
super._init({
style_class: 'check-box',
child: container,
button_mask: St.ButtonMask.ONE,
toggle_mode: true,
can_focus: true,
});
this.set_accessible_role(Atk.Role.CHECK_BOX);
this._box = new St.Bin({y_align: Clutter.ActorAlign.START});
container.add_child(this._box);
this._check = new St.Icon({
icon_name: 'check-symbolic',
});
this._box.set_child(this._check);
this._label = new St.Label({y_align: Clutter.ActorAlign.CENTER});
this._label.clutter_text.set_line_wrap(true);
this._label.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE);
this.set_label_actor(this._label);
container.add_child(this._label);
if (label)
this.setLabel(label);
}
setLabel(label) {
this._label.set_text(label);
}
getLabelActor() {
return this._label;
}
});

216
js/ui/closeDialog.js Normal file
View file

@ -0,0 +1,216 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Dialog from './dialog.js';
import * as Main from './main.js';
const FROZEN_WINDOW_BRIGHTNESS = -0.3;
const DIALOG_TRANSITION_TIME = 150;
const ALIVE_TIMEOUT = 5000;
export const CloseDialog = GObject.registerClass({
Implements: [Meta.CloseDialog],
Properties: {
'window': GObject.ParamSpec.override('window', Meta.CloseDialog),
},
}, class CloseDialog extends GObject.Object {
_init(window) {
super._init();
this._window = window;
this._dialog = null;
this._tracked = undefined;
this._timeoutId = 0;
}
get window() {
return this._window;
}
set window(window) {
this._window = window;
}
_createDialogContent() {
let tracker = Shell.WindowTracker.get_default();
let windowApp = tracker.get_window_app(this._window);
/* Translators: %s is an application name */
let title = _('“%s” Is Not Responding').format(windowApp.get_name());
let description = _('You may choose to wait a short while for it to ' +
'continue or force the app to quit entirely');
return new Dialog.MessageDialogContent({title, description});
}
_updateScale() {
// Since this is a child of MetaWindowActor (which, for Wayland clients,
// applies the geometry scale factor to its children itself, see
// meta_window_actor_set_geometry_scale()), make sure we don't apply
// the factor twice in the end.
if (this._window.get_client_type() !== Meta.WindowClientType.WAYLAND)
return;
let {scaleFactor} = St.ThemeContext.get_for_stage(global.stage);
this._dialog.set_scale(1 / scaleFactor, 1 / scaleFactor);
}
_initDialog() {
if (this._dialog)
return;
let windowActor = this._window.get_compositor_private();
this._dialog = new Dialog.Dialog(windowActor, 'close-dialog');
this._dialog.width = windowActor.width;
this._dialog.height = windowActor.height;
this._dialog.contentLayout.add_child(this._createDialogContent());
this._dialog.addButton({
label: _('Force Quit'),
action: this._onClose.bind(this),
default: true,
});
this._dialog.addButton({
label: _('Wait'),
action: this._onWait.bind(this),
key: Clutter.KEY_Escape,
});
global.focus_manager.add_group(this._dialog);
let themeContext = St.ThemeContext.get_for_stage(global.stage);
themeContext.connect('notify::scale-factor', this._updateScale.bind(this));
this._updateScale();
}
_addWindowEffect() {
// We set the effect on the surface actor, so the dialog itself
// (which is a child of the MetaWindowActor) does not get the
// effect applied itself.
let windowActor = this._window.get_compositor_private();
let surfaceActor = windowActor.get_first_child();
let effect = new Clutter.BrightnessContrastEffect();
effect.set_brightness(FROZEN_WINDOW_BRIGHTNESS);
surfaceActor.add_effect_with_name('gnome-shell-frozen-window', effect);
}
_removeWindowEffect() {
let windowActor = this._window.get_compositor_private();
let surfaceActor = windowActor.get_first_child();
surfaceActor.remove_effect_by_name('gnome-shell-frozen-window');
}
_onWait() {
this.response(Meta.CloseDialogResponse.WAIT);
}
_onClose() {
this.response(Meta.CloseDialogResponse.FORCE_CLOSE);
}
_onFocusChanged() {
if (Meta.is_wayland_compositor())
return;
let focusWindow = global.display.focus_window;
let keyFocus = global.stage.key_focus;
let shouldTrack;
if (focusWindow != null)
shouldTrack = focusWindow === this._window;
else
shouldTrack = keyFocus && this._dialog.contains(keyFocus);
if (this._tracked === shouldTrack)
return;
if (shouldTrack) {
Main.layoutManager.trackChrome(this._dialog,
{affectsInputRegion: true});
} else {
Main.layoutManager.untrackChrome(this._dialog);
}
// The buttons are broken when they aren't added to the input region,
// so disable them properly in that case
this._dialog.buttonLayout.get_children().forEach(b => {
b.reactive = shouldTrack;
});
this._tracked = shouldTrack;
}
vfunc_show() {
if (this._dialog != null)
return;
global.compositor.disable_unredirect();
this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ALIVE_TIMEOUT,
() => {
this._window.check_alive(global.display.get_current_time_roundtrip());
return GLib.SOURCE_CONTINUE;
});
global.display.connectObject(
'notify::focus-window', this._onFocusChanged.bind(this), this);
global.stage.connectObject(
'notify::key-focus', this._onFocusChanged.bind(this), this);
this._addWindowEffect();
this._initDialog();
global.connectObject(
'shutdown', () => this._onWait(), this._dialog);
this._dialog._dialog.scale_y = 0;
this._dialog._dialog.set_pivot_point(0.5, 0.5);
this._dialog._dialog.ease({
scale_y: 1,
mode: Clutter.AnimationMode.LINEAR,
duration: DIALOG_TRANSITION_TIME,
onComplete: this._onFocusChanged.bind(this),
});
}
vfunc_hide() {
if (this._dialog == null)
return;
global.compositor.enable_unredirect();
GLib.source_remove(this._timeoutId);
this._timeoutId = 0;
global.display.disconnectObject(this);
global.stage.disconnectObject(this);
this._dialog._dialog.remove_all_transitions();
let dialog = this._dialog;
this._dialog = null;
this._removeWindowEffect();
dialog.makeInactive();
dialog._dialog.ease({
scale_y: 0,
mode: Clutter.AnimationMode.LINEAR,
duration: DIALOG_TRANSITION_TIME,
onComplete: () => dialog.destroy(),
});
}
vfunc_focus() {
if (!this._dialog)
return;
const keyFocus = global.stage.key_focus;
if (!keyFocus || !this._dialog.contains(keyFocus))
this._dialog.initialKeyFocus.grab_key_focus();
}
});

60
js/ui/components.js Normal file
View file

@ -0,0 +1,60 @@
import * as Main from './main.js';
export class ComponentManager {
constructor() {
this._allComponents = {};
this._enabledComponents = [];
Main.sessionMode.connect('updated', () => {
this._sessionModeUpdated().catch(logError);
});
this._sessionModeUpdated().catch(logError);
}
async _sessionModeUpdated() {
let newEnabledComponents = Main.sessionMode.components;
await Promise.allSettled([...newEnabledComponents
.filter(name => !this._enabledComponents.includes(name))
.map(name => this._enableComponent(name))]);
this._enabledComponents
.filter(name => !newEnabledComponents.includes(name))
.forEach(name => this._disableComponent(name));
this._enabledComponents = newEnabledComponents;
}
async _importComponent(name) {
let module = await import(`./components/${name}.js`);
return module.Component;
}
async _ensureComponent(name) {
let component = this._allComponents[name];
if (component)
return component;
if (Main.sessionMode.isLocked)
return null;
let constructor = await this._importComponent(name);
component = new constructor();
this._allComponents[name] = component;
return component;
}
async _enableComponent(name) {
let component = await this._ensureComponent(name);
if (component)
component.enable();
}
_disableComponent(name) {
let component = this._allComponents[name];
if (component == null)
return;
component.disable();
}
}

View file

@ -0,0 +1,257 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as Params from '../../misc/params.js';
import * as GnomeSession from '../../misc/gnomeSession.js';
import * as Main from '../main.js';
import * as ShellMountOperation from '../shellMountOperation.js';
const GNOME_SESSION_AUTOMOUNT_INHIBIT = 16;
// GSettings keys
const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling';
const SETTING_ENABLE_AUTOMOUNT = 'automount';
const AUTORUN_EXPIRE_TIMEOUT_SECS = 10;
class AutomountManager {
constructor() {
this._settings = new Gio.Settings({schema_id: SETTINGS_SCHEMA});
this._activeOperations = new Map();
this._session = new GnomeSession.SessionManager();
this._session.connectSignal('InhibitorAdded',
this._InhibitorsChanged.bind(this));
this._session.connectSignal('InhibitorRemoved',
this._InhibitorsChanged.bind(this));
this._inhibited = false;
this._volumeMonitor = Gio.VolumeMonitor.get();
}
enable() {
this._volumeMonitor.connectObject(
'volume-added', this._onVolumeAdded.bind(this),
'volume-removed', this._onVolumeRemoved.bind(this),
'drive-connected', this._onDriveConnected.bind(this),
'drive-disconnected', this._onDriveDisconnected.bind(this),
'drive-eject-button', this._onDriveEjectButton.bind(this), this);
this._mountAllId = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._startupMountAll.bind(this));
GLib.Source.set_name_by_id(this._mountAllId, '[gnome-shell] this._startupMountAll');
}
disable() {
this._volumeMonitor.disconnectObject(this);
if (this._mountAllId > 0) {
GLib.source_remove(this._mountAllId);
this._mountAllId = 0;
}
}
async _InhibitorsChanged(_object, _senderName, [_inhibitor]) {
try {
const [inhibited] =
await this._session.IsInhibitedAsync(GNOME_SESSION_AUTOMOUNT_INHIBIT);
this._inhibited = inhibited;
} catch {}
}
_startupMountAll() {
let volumes = this._volumeMonitor.get_volumes();
volumes.forEach(volume => {
this._checkAndMountVolume(volume, {
checkSession: false,
useMountOp: false,
allowAutorun: false,
});
});
this._mountAllId = 0;
return GLib.SOURCE_REMOVE;
}
_onDriveConnected() {
// if we're not in the current ConsoleKit session,
// or screensaver is active, don't play sounds
if (!this._session.SessionIsActive)
return;
let player = global.display.get_sound_player();
player.play_from_theme('device-added-media',
_('External drive connected'),
null);
}
_onDriveDisconnected() {
// if we're not in the current ConsoleKit session,
// or screensaver is active, don't play sounds
if (!this._session.SessionIsActive)
return;
let player = global.display.get_sound_player();
player.play_from_theme('device-removed-media',
_('External drive disconnected'),
null);
}
_onDriveEjectButton(monitor, drive) {
// TODO: this code path is not tested, as the GVfs volume monitor
// doesn't emit this signal just yet.
if (!this._session.SessionIsActive)
return;
// we force stop/eject in this case, so we don't have to pass a
// mount operation object
if (drive.can_stop()) {
drive.stop(Gio.MountUnmountFlags.FORCE, null, null,
(o, res) => {
try {
drive.stop_finish(res);
} catch (e) {
log(`Unable to stop the drive after drive-eject-button ${e.toString()}`);
}
});
} else if (drive.can_eject()) {
drive.eject_with_operation(Gio.MountUnmountFlags.FORCE, null, null,
(o, res) => {
try {
drive.eject_with_operation_finish(res);
} catch (e) {
log(`Unable to eject the drive after drive-eject-button ${e.toString()}`);
}
});
}
}
_onVolumeAdded(monitor, volume) {
this._checkAndMountVolume(volume);
}
_checkAndMountVolume(volume, params) {
params = Params.parse(params, {
checkSession: true,
useMountOp: true,
allowAutorun: true,
});
if (params.checkSession) {
// if we're not in the current ConsoleKit session,
// don't attempt automount
if (!this._session.SessionIsActive)
return;
}
if (this._inhibited)
return;
// Volume is already mounted, don't bother.
if (volume.get_mount())
return;
if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) ||
!volume.should_automount() ||
!volume.can_mount()) {
// allow the autorun to run anyway; this can happen if the
// mount gets added programmatically later, even if
// should_automount() or can_mount() are false, like for
// blank optical media.
this._allowAutorun(volume);
this._allowAutorunExpire(volume);
return;
}
if (params.useMountOp) {
let operation = new ShellMountOperation.ShellMountOperation(volume);
this._mountVolume(volume, operation, params.allowAutorun);
} else {
this._mountVolume(volume, null, params.allowAutorun);
}
}
_mountVolume(volume, operation, allowAutorun) {
if (allowAutorun)
this._allowAutorun(volume);
const mountOp = operation?.mountOp ?? null;
this._activeOperations.set(volume, operation);
volume.mount(0, mountOp, null,
this._onVolumeMounted.bind(this));
}
_onVolumeMounted(volume, res) {
this._allowAutorunExpire(volume);
try {
volume.mount_finish(res);
this._closeOperation(volume);
} catch (e) {
// FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks
// backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271
// To reask the password if the user input was empty or wrong, we
// will check for corresponding error messages. However, these
// error strings are not unique for the cases in the comments below.
if (e.message.includes('No key available with this passphrase') || // cryptsetup
e.message.includes('No key available to unlock device') || // udisks (no password)
// libblockdev wrong password opening LUKS device
e.message.includes('Failed to activate device: Incorrect passphrase') ||
// cryptsetup returns EINVAL (< v2.5.0) or EPERM (>= v2.5.0)
// when the TCRYPT header can't be decrypted with the provided
// password/parameters.
e.message.includes('Failed to load device\'s parameters: Invalid argument') ||
e.message.includes('Failed to load device\'s parameters: Operation not permitted')) {
this._reaskPassword(volume);
} else {
if (e.message.includes('Compiled against a version of libcryptsetup that does not support the VeraCrypt PIM setting')) {
Main.notifyError(_('Unable to unlock volume'),
_('The installed udisks version does not support the PIM setting'));
}
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED))
log(`Unable to mount volume ${volume.get_name()}: ${e.toString()}`);
this._closeOperation(volume);
}
}
}
_onVolumeRemoved(monitor, volume) {
if (volume._allowAutorunExpireId && volume._allowAutorunExpireId > 0) {
GLib.source_remove(volume._allowAutorunExpireId);
delete volume._allowAutorunExpireId;
}
}
_reaskPassword(volume) {
let prevOperation = this._activeOperations.get(volume);
const existingDialog = prevOperation?.borrowDialog();
let operation =
new ShellMountOperation.ShellMountOperation(volume, {existingDialog});
this._mountVolume(volume, operation);
}
_closeOperation(volume) {
let operation = this._activeOperations.get(volume);
if (!operation)
return;
operation.close();
this._activeOperations.delete(volume);
}
_allowAutorun(volume) {
volume.allowAutorun = true;
}
_allowAutorunExpire(volume) {
let id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTORUN_EXPIRE_TIMEOUT_SECS, () => {
volume.allowAutorun = false;
delete volume._allowAutorunExpireId;
return GLib.SOURCE_REMOVE;
});
volume._allowAutorunExpireId = id;
GLib.Source.set_name_by_id(id, '[gnome-shell] volume.allowAutorun');
}
}
export {AutomountManager as Component};

View file

@ -0,0 +1,253 @@
import Gio from 'gi://Gio';
import * as GnomeSession from '../../misc/gnomeSession.js';
import * as MessageTray from '../messageTray.js';
Gio._promisify(Gio.Mount.prototype, 'guess_content_type');
import {loadInterfaceXML} from '../../misc/fileUtils.js';
// GSettings keys
const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling';
const SETTING_DISABLE_AUTORUN = 'autorun-never';
const SETTING_START_APP = 'autorun-x-content-start-app';
const SETTING_IGNORE = 'autorun-x-content-ignore';
const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder';
/** @enum {number} */
const AutorunSetting = {
RUN: 0,
IGNORE: 1,
FILES: 2,
ASK: 3,
};
// misc utils
function shouldAutorunMount(mount) {
let root = mount.get_root();
let volume = mount.get_volume();
if (!volume || !volume.allowAutorun)
return false;
if (root.is_native() && isMountRootHidden(root))
return false;
return true;
}
function isMountRootHidden(root) {
let path = root.get_path();
// skip any mounts in hidden directory hierarchies
return path.includes('/.');
}
function isMountNonLocal(mount) {
// If the mount doesn't have an associated volume, that means it's
// an uninteresting filesystem. Most devices that we care about will
// have a mount, like media players and USB sticks.
let volume = mount.get_volume();
if (volume == null)
return true;
return volume.get_identifier('class') === 'network';
}
function startAppForMount(app, mount) {
let files = [];
let root = mount.get_root();
let retval = false;
files.push(root);
try {
retval = app.launch(files,
global.create_app_launch_context(0, -1));
} catch (e) {
log(`Unable to launch the app ${app.get_name()}: ${e}`);
}
return retval;
}
const HotplugSnifferIface = loadInterfaceXML('org.gnome.Shell.HotplugSniffer');
const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface);
function HotplugSniffer() {
return new HotplugSnifferProxy(Gio.DBus.session,
'org.gnome.Shell.HotplugSniffer',
'/org/gnome/Shell/HotplugSniffer');
}
class ContentTypeDiscoverer {
constructor() {
this._settings = new Gio.Settings({schema_id: SETTINGS_SCHEMA});
}
async guessContentTypes(mount) {
let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN);
let shouldScan = autorunEnabled && !isMountNonLocal(mount);
let contentTypes = [];
if (shouldScan) {
try {
contentTypes = await mount.guess_content_type(false, null);
} catch (e) {
log(`Unable to guess content types on added mount ${mount.get_name()}: ${e}`);
}
if (contentTypes.length === 0) {
const root = mount.get_root();
const hotplugSniffer = new HotplugSniffer();
[contentTypes] = await hotplugSniffer.SniffURIAsync(root.get_uri());
}
}
// we're not interested in win32 software content types here
contentTypes = contentTypes.filter(
type => type !== 'x-content/win32-software');
const apps = [];
contentTypes.forEach(type => {
const app = Gio.app_info_get_default_for_type(type, false);
if (app)
apps.push(app);
});
if (apps.length === 0)
apps.push(Gio.app_info_get_default_for_type('inode/directory', false));
return [apps, contentTypes];
}
}
class AutorunManager {
constructor() {
this._session = new GnomeSession.SessionManager();
this._volumeMonitor = Gio.VolumeMonitor.get();
this._dispatcher = new AutorunDispatcher(this);
}
enable() {
this._volumeMonitor.connectObject(
'mount-added', this._onMountAdded.bind(this),
'mount-removed', this._onMountRemoved.bind(this), this);
}
disable() {
this._volumeMonitor.disconnectObject(this);
}
async _onMountAdded(monitor, mount) {
// don't do anything if our session is not the currently
// active one
if (!this._session.SessionIsActive)
return;
const discoverer = new ContentTypeDiscoverer();
const [apps, contentTypes] = await discoverer.guessContentTypes(mount);
this._dispatcher.addMount(mount, apps, contentTypes);
}
_onMountRemoved(monitor, mount) {
this._dispatcher.removeMount(mount);
}
}
class AutorunDispatcher {
constructor(manager) {
this._manager = manager;
this._notifications = new Map();
this._settings = new Gio.Settings({schema_id: SETTINGS_SCHEMA});
}
_getAutorunSettingForType(contentType) {
let runApp = this._settings.get_strv(SETTING_START_APP);
if (runApp.includes(contentType))
return AutorunSetting.RUN;
let ignore = this._settings.get_strv(SETTING_IGNORE);
if (ignore.includes(contentType))
return AutorunSetting.IGNORE;
let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER);
if (openFiles.includes(contentType))
return AutorunSetting.FILES;
return AutorunSetting.ASK;
}
_addNotification(mount, apps) {
// Only show a new notification if there isn't already an existing one
if (this._notifications.has(mount))
return;
const source = MessageTray.getSystemSource();
/* Translators: %s is the name of a partition on a external drive */
const title = _('“%s” connected'.format(mount.get_name()));
const body = _('Disk can now be used');
const notification = new MessageTray.Notification({
source,
title,
body,
});
notification.connect('activate', () => {
const app = Gio.app_info_get_default_for_type('inode/directory', false);
startAppForMount(app, mount);
});
apps.forEach(app => {
notification.addAction(
_('Open with %s').format(app.get_name()),
() => startAppForMount(app, mount)
);
});
notification.connect('destroy', () => this._notifications.delete(mount));
this._notifications.set(mount, notification);
source.addNotification(notification);
}
addMount(mount, apps, contentTypes) {
// if autorun is disabled globally, return
if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN))
return;
// if the mount doesn't want to be autorun, return
if (!shouldAutorunMount(mount))
return;
let setting;
if (contentTypes.length > 0)
setting = this._getAutorunSettingForType(contentTypes[0]);
else
setting = AutorunSetting.ASK;
// check at the settings for the first content type
// to see whether we should ask
if (setting === AutorunSetting.IGNORE)
return; // return right away
let success = false;
let app = null;
if (setting === AutorunSetting.RUN)
app = Gio.app_info_get_default_for_type(contentTypes[0], false);
else if (setting === AutorunSetting.FILES)
app = Gio.app_info_get_default_for_type('inode/directory', false);
if (app)
success = startAppForMount(app, mount);
// we fallback here also in case the settings did not specify 'ask',
// but we failed launching the default app or the default file manager
if (!success)
this._addNotification(mount, apps);
}
removeMount(mount) {
this._notifications.get(mount)?.destroy();
}
}
export {AutorunManager as Component};

232
js/ui/components/keyring.js Normal file
View file

@ -0,0 +1,232 @@
import Clutter from 'gi://Clutter';
import Gcr from 'gi://Gcr';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Dialog from '../dialog.js';
import * as ModalDialog from '../modalDialog.js';
import * as ShellEntry from '../shellEntry.js';
import * as CheckBox from '../checkBox.js';
import {wiggle} from '../../misc/animationUtils.js';
const KeyringDialog = GObject.registerClass(
class KeyringDialog extends ModalDialog.ModalDialog {
_init() {
super._init({styleClass: 'prompt-dialog'});
this.prompt = new Shell.KeyringPrompt();
this.prompt.connect('show-password', this._onShowPassword.bind(this));
this.prompt.connect('show-confirm', this._onShowConfirm.bind(this));
this.prompt.connect('prompt-close', this._onHidePrompt.bind(this));
let content = new Dialog.MessageDialogContent();
this.prompt.bind_property('message',
content, 'title', GObject.BindingFlags.SYNC_CREATE);
this.prompt.bind_property('description',
content, 'description', GObject.BindingFlags.SYNC_CREATE);
let passwordBox = new St.BoxLayout({
style_class: 'prompt-dialog-password-layout',
orientation: Clutter.Orientation.VERTICAL,
});
this._passwordEntry = new St.PasswordEntry({
style_class: 'prompt-dialog-password-entry',
can_focus: true,
x_align: Clutter.ActorAlign.CENTER,
});
ShellEntry.addContextMenu(this._passwordEntry);
this._passwordEntry.clutter_text.connect('activate', this._onPasswordActivate.bind(this));
this.prompt.bind_property('password-visible',
this._passwordEntry, 'visible', GObject.BindingFlags.SYNC_CREATE);
passwordBox.add_child(this._passwordEntry);
this._confirmEntry = new St.PasswordEntry({
style_class: 'prompt-dialog-password-entry',
can_focus: true,
x_align: Clutter.ActorAlign.CENTER,
});
ShellEntry.addContextMenu(this._confirmEntry);
this._confirmEntry.clutter_text.connect('activate', this._onConfirmActivate.bind(this));
this.prompt.bind_property('confirm-visible',
this._confirmEntry, 'visible', GObject.BindingFlags.SYNC_CREATE);
passwordBox.add_child(this._confirmEntry);
this.prompt.set_password_actor(this._passwordEntry.clutter_text);
this.prompt.set_confirm_actor(this._confirmEntry.clutter_text);
let warningBox = new St.BoxLayout({orientation: Clutter.Orientation.VERTICAL});
let capsLockWarning = new ShellEntry.CapsLockWarning();
let syncCapsLockWarningVisibility = () => {
capsLockWarning.visible =
this.prompt.password_visible || this.prompt.confirm_visible;
};
this.prompt.connect('notify::password-visible', syncCapsLockWarningVisibility);
this.prompt.connect('notify::confirm-visible', syncCapsLockWarningVisibility);
warningBox.add_child(capsLockWarning);
let warning = new St.Label({style_class: 'prompt-dialog-error-label'});
warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
warning.clutter_text.line_wrap = true;
this.prompt.bind_property('warning',
warning, 'text', GObject.BindingFlags.SYNC_CREATE);
this.prompt.connect('notify::warning-visible', () => {
warning.opacity = this.prompt.warning_visible ? 255 : 0;
});
this.prompt.connect('notify::warning', () => {
if (this._passwordEntry && this.prompt.warning !== '')
wiggle(this._passwordEntry);
});
warningBox.add_child(warning);
passwordBox.add_child(warningBox);
content.add_child(passwordBox);
this._choice = new CheckBox.CheckBox();
this.prompt.bind_property('choice-label', this._choice.getLabelActor(),
'text', GObject.BindingFlags.SYNC_CREATE);
this.prompt.bind_property('choice-chosen', this._choice,
'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL);
this.prompt.bind_property('choice-visible', this._choice,
'visible', GObject.BindingFlags.SYNC_CREATE);
content.add_child(this._choice);
this.contentLayout.add_child(content);
this._cancelButton = this.addButton({
label: '',
action: this._onCancelButton.bind(this),
key: Clutter.KEY_Escape,
});
this._continueButton = this.addButton({
label: '',
action: this._onContinueButton.bind(this),
default: true,
});
this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE);
this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE);
}
_updateSensitivity(sensitive) {
if (this._passwordEntry)
this._passwordEntry.reactive = sensitive;
if (this._confirmEntry)
this._confirmEntry.reactive = sensitive;
this._continueButton.can_focus = sensitive;
this._continueButton.reactive = sensitive;
}
_ensureOpen() {
// NOTE: ModalDialog.open() is safe to call if the dialog is
// already open - it just returns true without side-effects
if (this.open())
return true;
// The above fail if e.g. unable to get input grab
//
// In an ideal world this wouldn't happen (because the
// Shell is in complete control of the session) but that's
// just not how things work right now.
log('keyringPrompt: Failed to show modal dialog.' +
' Dismissing prompt request');
this.prompt.cancel();
return false;
}
_onShowPassword() {
this._ensureOpen();
this._updateSensitivity(true);
this._passwordEntry.text = '';
this._passwordEntry.grab_key_focus();
}
_onShowConfirm() {
this._ensureOpen();
this._updateSensitivity(true);
this._confirmEntry.text = '';
this._continueButton.grab_key_focus();
}
_onHidePrompt() {
this.close();
}
_onPasswordActivate() {
if (this.prompt.confirm_visible)
this._confirmEntry.grab_key_focus();
else
this._onContinueButton();
}
_onConfirmActivate() {
this._onContinueButton();
}
_onContinueButton() {
this._updateSensitivity(false);
this.prompt.complete();
}
_onCancelButton() {
this.prompt.cancel();
}
});
class KeyringDummyDialog {
constructor() {
this.prompt = new Shell.KeyringPrompt();
this.prompt.connect('show-password', this._cancelPrompt.bind(this));
this.prompt.connect('show-confirm', this._cancelPrompt.bind(this));
}
_cancelPrompt() {
this.prompt.cancel();
}
}
const KeyringPrompter = GObject.registerClass(
class KeyringPrompter extends Gcr.SystemPrompter {
_init() {
super._init();
this.connect('new-prompt', () => {
let dialog = this._enabled
? new KeyringDialog()
: new KeyringDummyDialog();
this._currentPrompt = dialog.prompt;
return this._currentPrompt;
});
this._dbusId = null;
this._registered = false;
this._enabled = false;
this._currentPrompt = null;
}
enable() {
if (!this._registered) {
this.register(Gio.DBus.session);
this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter',
Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, null, null);
this._registered = true;
}
this._enabled = true;
}
disable() {
this._enabled = false;
if (this.prompting)
this._currentPrompt.cancel();
this._currentPrompt = null;
}
});
export {KeyringPrompter as Component};

View file

@ -0,0 +1,877 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GioUnix from 'gi://GioUnix';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import NM from 'gi://NM';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Signals from '../../misc/signals.js';
import * as Dialog from '../dialog.js';
import * as Main from '../main.js';
import * as MessageTray from '../messageTray.js';
import * as ModalDialog from '../modalDialog.js';
import * as ShellEntry from '../shellEntry.js';
Gio._promisify(Shell.NetworkAgent.prototype, 'init_async');
Gio._promisify(Shell.NetworkAgent.prototype, 'search_vpn_plugin');
const VPN_UI_GROUP = 'VPN Plugin UI';
const NetworkSecretDialog = GObject.registerClass(
class NetworkSecretDialog extends ModalDialog.ModalDialog {
_init(agent, requestId, connection, settingName, hints, flags, contentOverride) {
super._init({styleClass: 'prompt-dialog'});
this._agent = agent;
this._requestId = requestId;
this._connection = connection;
this._settingName = settingName;
this._hints = hints;
if (contentOverride)
this._content = contentOverride;
else
this._content = this._getContent();
let contentBox = new Dialog.MessageDialogContent({
title: this._content.title,
description: this._content.message,
});
let initialFocusSet = false;
for (let i = 0; i < this._content.secrets.length; i++) {
let secret = this._content.secrets[i];
let reactive = secret.key != null;
let entryParams = {
style_class: 'prompt-dialog-password-entry',
hint_text: secret.label,
text: secret.value,
can_focus: reactive,
reactive,
x_align: Clutter.ActorAlign.CENTER,
};
if (secret.password)
secret.entry = new St.PasswordEntry(entryParams);
else
secret.entry = new St.Entry(entryParams);
ShellEntry.addContextMenu(secret.entry);
contentBox.add_child(secret.entry);
if (secret.validate)
secret.valid = secret.validate(secret);
else // no special validation, just ensure it's not empty
secret.valid = secret.value.length > 0;
if (reactive) {
if (!initialFocusSet) {
this.setInitialKeyFocus(secret.entry);
initialFocusSet = true;
}
secret.entry.clutter_text.connect('activate', this._onOk.bind(this));
secret.entry.clutter_text.connect('text-changed', () => {
secret.value = secret.entry.get_text();
if (secret.validate)
secret.valid = secret.validate(secret);
else
secret.valid = secret.value.length > 0;
this._updateOkButton();
});
} else {
secret.valid = true;
}
}
if (this._content.secrets.some(s => s.password)) {
let capsLockWarning = new ShellEntry.CapsLockWarning();
contentBox.add_child(capsLockWarning);
}
if (flags & NM.SecretAgentGetSecretsFlags.WPS_PBC_ACTIVE) {
let descriptionLabel = new St.Label({
text: _('Alternatively you can connect by pushing the “WPS” button on your router'),
style_class: 'message-dialog-description',
});
descriptionLabel.clutter_text.line_wrap = true;
descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
contentBox.add_child(descriptionLabel);
}
this.contentLayout.add_child(contentBox);
this._okButton = {
label: _('Connect'),
action: this._onOk.bind(this),
default: true,
};
this.setButtons([{
label: _('Cancel'),
action: this.cancel.bind(this),
key: Clutter.KEY_Escape,
}, this._okButton]);
this._updateOkButton();
}
_updateOkButton() {
let valid = true;
for (let i = 0; i < this._content.secrets.length; i++) {
let secret = this._content.secrets[i];
valid &&= secret.valid;
}
this._okButton.button.reactive = valid;
this._okButton.button.can_focus = valid;
}
_onOk() {
let valid = true;
for (let i = 0; i < this._content.secrets.length; i++) {
let secret = this._content.secrets[i];
valid &&= secret.valid;
if (secret.key !== null) {
if (this._settingName === 'vpn')
this._agent.add_vpn_secret(this._requestId, secret.key, secret.value);
else
this._agent.set_password(this._requestId, secret.key, secret.value);
}
}
if (valid) {
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
this.close();
}
// do nothing if not valid
}
cancel() {
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
this.close();
}
_validateWpaPsk(secret) {
let value = secret.value;
if (value.length === 64) {
// must be composed of hexadecimal digits only
for (let i = 0; i < 64; i++) {
if (!((value[i] >= 'a' && value[i] <= 'f') ||
(value[i] >= 'A' && value[i] <= 'F') ||
(value[i] >= '0' && value[i] <= '9')))
return false;
}
return true;
}
return value.length >= 8 && value.length <= 63;
}
_validateStaticWep(secret) {
let value = secret.value;
if (secret.wep_key_type === NM.WepKeyType.KEY) {
if (value.length === 10 || value.length === 26) {
for (let i = 0; i < value.length; i++) {
if (!((value[i] >= 'a' && value[i] <= 'f') ||
(value[i] >= 'A' && value[i] <= 'F') ||
(value[i] >= '0' && value[i] <= '9')))
return false;
}
} else if (value.length === 5 || value.length === 13) {
for (let i = 0; i < value.length; i++) {
if (!((value[i] >= 'a' && value[i] <= 'z') ||
(value[i] >= 'A' && value[i] <= 'Z')))
return false;
}
} else {
return false;
}
} else if (secret.wep_key_type === NM.WepKeyType.PASSPHRASE) {
if (value.length < 0 || value.length > 64)
return false;
}
return true;
}
_getWirelessSecrets(secrets, _wirelessSetting) {
let wirelessSecuritySetting = this._connection.get_setting_wireless_security();
if (this._settingName === '802-1x') {
this._get8021xSecrets(secrets);
return;
}
switch (wirelessSecuritySetting.key_mgmt) {
// First the easy ones
case 'wpa-none':
case 'wpa-psk':
case 'sae':
secrets.push({
label: _('Password'),
key: 'psk',
value: wirelessSecuritySetting.psk || '',
validate: this._validateWpaPsk,
password: true,
});
break;
case 'none': // static WEP
secrets.push({
label: _('Key'),
key: `wep-key${wirelessSecuritySetting.wep_tx_keyidx}`,
value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '',
wep_key_type: wirelessSecuritySetting.wep_key_type,
validate: this._validateStaticWep,
password: true,
});
break;
case 'ieee8021x':
if (wirelessSecuritySetting.auth_alg === 'leap') { // Cisco LEAP
secrets.push({
label: _('Password'),
key: 'leap-password',
value: wirelessSecuritySetting.leap_password || '',
password: true,
});
} else { // Dynamic (IEEE 802.1x) WEP
this._get8021xSecrets(secrets);
}
break;
case 'wpa-eap':
this._get8021xSecrets(secrets);
break;
default:
log(`Invalid wireless key management: ${wirelessSecuritySetting.key_mgmt}`);
}
}
_get8021xSecrets(secrets) {
let ieee8021xSetting = this._connection.get_setting_802_1x();
/* If hints were given we know exactly what we need to ask */
if (this._settingName === '802-1x' && this._hints.length) {
if (this._hints.includes('identity')) {
secrets.push({
label: _('Username'),
key: 'identity',
value: ieee8021xSetting.identity || '',
password: false,
});
}
if (this._hints.includes('password')) {
secrets.push({
label: _('Password'),
key: 'password',
value: ieee8021xSetting.password || '',
password: true,
});
}
if (this._hints.includes('private-key-password')) {
secrets.push({
label: _('Private key password'),
key: 'private-key-password',
value: ieee8021xSetting.private_key_password || '',
password: true,
});
}
return;
}
switch (ieee8021xSetting.get_eap_method(0)) {
case 'md5':
case 'leap':
case 'ttls':
case 'peap':
case 'fast':
// TTLS and PEAP are actually much more complicated, but this complication
// is not visible here since we only care about phase2 authentication
// (and don't even care of which one)
secrets.push({
label: _('Username'),
key: null,
value: ieee8021xSetting.identity || '',
password: false,
});
secrets.push({
label: _('Password'),
key: 'password',
value: ieee8021xSetting.password || '',
password: true,
});
break;
case 'tls':
secrets.push({
label: _('Identity'),
key: null,
value: ieee8021xSetting.identity || '',
password: false,
});
secrets.push({
label: _('Private key password'),
key: 'private-key-password',
value: ieee8021xSetting.private_key_password || '',
password: true,
});
break;
default:
log(`Invalid EAP/IEEE802.1x method: ${ieee8021xSetting.get_eap_method(0)}`);
}
}
_getPPPoESecrets(secrets) {
let pppoeSetting = this._connection.get_setting_pppoe();
secrets.push({
label: _('Username'),
key: 'username',
value: pppoeSetting.username || '',
password: false,
});
secrets.push({
label: _('Service'), key: 'service',
value: pppoeSetting.service || '',
password: false,
});
secrets.push({
label: _('Password'), key: 'password',
value: pppoeSetting.password || '',
password: true,
});
}
_getMobileSecrets(secrets, connectionType) {
let setting;
if (connectionType === 'bluetooth')
setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm();
else
setting = this._connection.get_setting_by_name(connectionType);
secrets.push({
label: _('Password'),
key: 'password',
value: setting.value || '',
password: true,
});
}
_getContent() {
let connectionSetting = this._connection.get_setting_connection();
let connectionType = connectionSetting.get_connection_type();
let wirelessSetting;
let ssid;
let content = { };
content.secrets = [];
switch (connectionType) {
case '802-11-wireless':
wirelessSetting = this._connection.get_setting_wireless();
ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data());
content.title = _('Authentication required');
content.message = _('Passwords or encryption keys are required to access the wireless network “%s”').format(ssid);
this._getWirelessSecrets(content.secrets, wirelessSetting);
break;
case '802-3-ethernet':
content.title = _('Wired 802.1X authentication');
content.message = null;
content.secrets.push({
label: _('Network name'),
key: null,
value: connectionSetting.get_id(),
password: false,
});
this._get8021xSecrets(content.secrets);
break;
case 'pppoe':
content.title = _('DSL authentication');
content.message = null;
this._getPPPoESecrets(content.secrets);
break;
case 'gsm':
if (this._hints.includes('pin')) {
let gsmSetting = this._connection.get_setting_gsm();
content.title = _('PIN code required');
content.message = _('PIN code is needed for the mobile broadband device');
content.secrets.push({
label: _('PIN'),
key: 'pin',
value: gsmSetting.pin || '',
password: true,
});
break;
}
// fall through
case 'cdma':
case 'bluetooth':
content.title = _('Authentication required');
content.message = _('A password is required to connect to “%s”').format(connectionSetting.get_id());
this._getMobileSecrets(content.secrets, connectionType);
break;
default:
log(`Invalid connection type: ${connectionType}`);
}
return content;
}
});
class VPNRequestHandler extends Signals.EventEmitter {
constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) {
super();
this._agent = agent;
this._requestId = requestId;
this._connection = connection;
this._flags = flags;
this._pluginOutBuffer = [];
this._title = null;
this._description = null;
this._content = [];
this._shellDialog = null;
let connectionSetting = connection.get_setting_connection();
const argv = [
authHelper.fileName,
'-u', connectionSetting.uuid,
'-n', connectionSetting.id,
'-s', serviceType,
];
if (authHelper.externalUIMode)
argv.push('--external-ui-mode');
if (flags & NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION)
argv.push('-i');
if (flags & NM.SecretAgentGetSecretsFlags.REQUEST_NEW)
argv.push('-r');
if (authHelper.supportsHints) {
for (let i = 0; i < hints.length; i++) {
argv.push('-t');
argv.push(hints[i]);
}
}
this._newStylePlugin = authHelper.externalUIMode;
try {
const launchContext = global.create_app_launch_context(0, -1);
let [pid, stdin, stdout, stderr] =
Shell.util_spawn_async_with_pipes(
null, /* pwd */
argv,
launchContext.get_environment(),
GLib.SpawnFlags.DO_NOT_REAP_CHILD);
this._childPid = pid;
this._stdin = new GioUnix.OutputStream({fd: stdin, close_fd: true});
this._stdout = new GioUnix.InputStream({fd: stdout, close_fd: true});
GLib.close(stderr);
this._dataStdout = new Gio.DataInputStream({base_stream: this._stdout});
if (this._newStylePlugin)
this._readStdoutNewStyle();
else
this._readStdoutOldStyle();
this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid,
this._vpnChildFinished.bind(this));
this._writeConnection();
} catch (e) {
logError(e, 'error while spawning VPN auth helper');
this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
}
}
cancel(respond) {
if (respond)
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
if (this._newStylePlugin && this._shellDialog) {
this._shellDialog.close();
this._shellDialog.destroy();
} else {
try {
this._stdin.write('QUIT\n\n', null);
} catch { /* ignore broken pipe errors */ }
}
this.destroy();
}
destroy() {
if (this._destroyed)
return;
this.emit('destroy');
if (this._childWatch)
GLib.source_remove(this._childWatch);
this._stdin.close(null);
// Stdout is closed when we finish reading from it
this._destroyed = true;
}
_vpnChildFinished(pid, status, _requestObj) {
this._childWatch = 0;
if (this._newStylePlugin) {
// For new style plugin, all work is done in the async reading functions
// Just reap the process here
return;
}
let [exited, exitStatus] = Shell.util_wifexited(status);
if (exited) {
if (exitStatus !== 0)
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
else
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
} else {
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
}
this.destroy();
}
_vpnChildProcessLineOldStyle(line) {
if (this._previousLine !== undefined) {
// Two consecutive newlines mean that the child should be closed
// (the actual newlines are eaten by Gio.DataInputStream)
// Send a termination message
if (line === '' && this._previousLine === '') {
try {
this._stdin.write('QUIT\n\n', null);
} catch { /* ignore broken pipe errors */ }
} else {
this._agent.add_vpn_secret(this._requestId, this._previousLine, line);
this._previousLine = undefined;
}
} else {
this._previousLine = line;
}
}
async _readStdoutOldStyle() {
const [line, len_] =
await this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null);
if (line === null) {
// end of file
this._stdout.close(null);
return;
}
const decoder = new TextDecoder();
this._vpnChildProcessLineOldStyle(decoder.decode(line));
// try to read more!
this._readStdoutOldStyle();
}
async _readStdoutNewStyle() {
const cnt =
await this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null);
if (cnt === 0) {
// end of file
this._showNewStyleDialog();
this._stdout.close(null);
return;
}
// Try to read more
this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size());
this._readStdoutNewStyle();
}
_showNewStyleDialog() {
let keyfile = new GLib.KeyFile();
let data;
let contentOverride;
try {
data = new GLib.Bytes(this._dataStdout.peek_buffer());
keyfile.load_from_bytes(data, GLib.KeyFileFlags.NONE);
if (keyfile.get_integer(VPN_UI_GROUP, 'Version') !== 2)
throw new Error('Invalid plugin keyfile version, is %d');
contentOverride = {
title: keyfile.get_string(VPN_UI_GROUP, 'Title'),
message: keyfile.get_string(VPN_UI_GROUP, 'Description'),
secrets: [],
};
let [groups, len_] = keyfile.get_groups();
for (let i = 0; i < groups.length; i++) {
if (groups[i] === VPN_UI_GROUP)
continue;
let value = keyfile.get_string(groups[i], 'Value');
let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk');
if (shouldAsk) {
contentOverride.secrets.push({
label: keyfile.get_string(groups[i], 'Label'),
key: groups[i],
value,
password: keyfile.get_boolean(groups[i], 'IsSecret'),
});
} else {
if (!value.length) // Ignore empty secrets
continue;
this._agent.add_vpn_secret(this._requestId, groups[i], value);
}
}
} catch (e) {
// No output is a valid case it means "both secrets are stored"
if (data.length > 0) {
logError(e, 'error while reading VPN plugin output keyfile');
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
this.destroy();
return;
}
}
if (contentOverride && contentOverride.secrets.length) {
// Only show the dialog if we actually have something to ask
this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride);
this._shellDialog.open();
} else {
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
this.destroy();
}
}
_writeConnection() {
let vpnSetting = this._connection.get_setting_vpn();
try {
vpnSetting.foreach_data_item((key, value) => {
this._stdin.write(`DATA_KEY=${key}\n`, null);
this._stdin.write(`DATA_VAL=${value || ''}\n\n`, null);
});
vpnSetting.foreach_secret((key, value) => {
this._stdin.write(`SECRET_KEY=${key}\n`, null);
this._stdin.write(`SECRET_VAL=${value || ''}\n\n`, null);
});
this._stdin.write('DONE\n\n', null);
} catch (e) {
logError(e, 'internal error while writing connection to helper');
this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
this.destroy();
}
}
}
class NetworkAgent {
constructor() {
this._native = new Shell.NetworkAgent({
identifier: 'org.gnome.Shell.NetworkAgent',
capabilities: NM.SecretAgentCapabilities.VPN_HINTS,
auto_register: false,
force_always_ask: Main.sessionMode.isGreeter,
});
this._dialogs = { };
this._vpnRequests = { };
this._notifications = { };
this._native.connect('new-request', this._newRequest.bind(this));
this._native.connect('cancel-request', this._cancelRequest.bind(this));
this._initialized = false;
this._initNative();
}
async _initNative() {
try {
await this._native.init_async(GLib.PRIORITY_DEFAULT, null);
this._initialized = true;
} catch (e) {
this._native = null;
logError(e, 'error initializing the NetworkManager Agent');
}
}
enable() {
if (!this._native)
return;
this._native.auto_register = true;
if (this._initialized && !this._native.registered)
this._native.register_async(null, null);
}
disable() {
let requestId;
for (requestId in this._dialogs)
this._dialogs[requestId].cancel();
this._dialogs = { };
for (requestId in this._vpnRequests)
this._vpnRequests[requestId].cancel(true);
this._vpnRequests = { };
for (requestId in this._notifications)
this._notifications[requestId].destroy();
this._notifications = { };
if (!this._native)
return;
this._native.auto_register = false;
if (this._initialized && this._native.registered)
this._native.unregister_async(null, null);
}
_showNotification(requestId, connection, settingName, hints, flags) {
let title, body;
let connectionSetting = connection.get_setting_connection();
let connectionType = connectionSetting.get_connection_type();
switch (connectionType) {
case '802-11-wireless': {
let wirelessSetting = connection.get_setting_wireless();
let ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data());
title = _('Authentication required');
body = _('Passwords or encryption keys are required to access the wireless network “%s”').format(ssid);
break;
}
case '802-3-ethernet':
title = _('Wired 802.1X authentication');
body = _('A password is required to connect to “%s”').format(connection.get_id());
break;
case 'pppoe':
title = _('DSL authentication');
body = _('A password is required to connect to “%s”').format(connection.get_id());
break;
case 'gsm':
if (hints.includes('pin')) {
title = _('PIN code required');
body = _('PIN code is needed for the mobile broadband device');
break;
}
// fall through
case 'cdma':
case 'bluetooth':
title = _('Authentication required');
body = _('A password is required to connect to “%s”').format(connectionSetting.get_id());
break;
case 'vpn':
title = _('VPN password');
body = _('A password is required to connect to “%s”').format(connectionSetting.get_id());
break;
default:
log(`Invalid connection type: ${connectionType}`);
this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
return;
}
const source = MessageTray.getSystemSource();
const notification = new MessageTray.Notification({source, title, body});
notification.iconName = 'dialog-password-symbolic';
notification.connect('activated', () => {
notification.answered = true;
this._handleRequest(requestId, connection, settingName, hints, flags);
});
this._notifications[requestId] = notification;
notification.connect('destroy', () => {
if (!notification.answered)
this._native.respond(requestId, Shell.NetworkAgentResponse.USER_CANCELED);
delete this._notifications[requestId];
});
source.addNotification(notification);
}
_newRequest(agent, requestId, connection, settingName, hints, flags) {
if (!(flags & NM.SecretAgentGetSecretsFlags.USER_REQUESTED))
this._showNotification(requestId, connection, settingName, hints, flags);
else
this._handleRequest(requestId, connection, settingName, hints, flags);
}
_handleRequest(requestId, connection, settingName, hints, flags) {
if (settingName === 'vpn') {
this._vpnRequest(requestId, connection, hints, flags);
return;
}
let dialog = new NetworkSecretDialog(this._native, requestId, connection, settingName, hints, flags);
dialog.connect('destroy', () => {
delete this._dialogs[requestId];
});
this._dialogs[requestId] = dialog;
dialog.open();
}
_cancelRequest(agent, requestId) {
if (this._dialogs[requestId]) {
this._dialogs[requestId].close();
this._dialogs[requestId].destroy();
delete this._dialogs[requestId];
} else if (this._vpnRequests[requestId]) {
this._vpnRequests[requestId].cancel(false);
delete this._vpnRequests[requestId];
}
}
async _vpnRequest(requestId, connection, hints, flags) {
let vpnSetting = connection.get_setting_vpn();
let serviceType = vpnSetting.service_type;
let binary = await this._findAuthBinary(serviceType);
if (!binary) {
log('Invalid VPN service type (cannot find authentication binary)');
/* cancel the auth process */
this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
return;
}
let vpnRequest = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags);
vpnRequest.connect('destroy', () => {
delete this._vpnRequests[requestId];
});
this._vpnRequests[requestId] = vpnRequest;
}
async _findAuthBinary(serviceType) {
let plugin;
try {
plugin = await this._native.search_vpn_plugin(serviceType);
} catch (e) {
logError(e);
return null;
}
const fileName = plugin.get_auth_dialog();
if (!GLib.file_test(fileName, GLib.FileTest.IS_EXECUTABLE)) {
log(`VPN plugin at ${fileName} is not executable`);
return null;
}
const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode');
const trimmedProp = prop?.trim().toLowerCase() ?? '';
return {
fileName,
supportsHints: plugin.supports_hints(),
externalUIMode: ['true', 'yes', 'on', '1'].includes(trimmedProp),
};
}
}
export {NetworkAgent as Component};

View file

@ -0,0 +1,474 @@
import AccountsService from 'gi://AccountsService';
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import PolkitAgent from 'gi://PolkitAgent';
import Polkit from 'gi://Polkit';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Dialog from '../dialog.js';
import * as Main from '../main.js';
import * as ModalDialog from '../modalDialog.js';
import * as ShellEntry from '../shellEntry.js';
import * as UserWidget from '../userWidget.js';
import {wiggle} from '../../misc/animationUtils.js';
/** @enum {number} */
const DialogMode = {
AUTH: 0,
CONFIRM: 1,
};
const DIALOG_ICON_SIZE = 96;
const DELAYED_RESET_TIMEOUT = 200;
const AuthenticationDialog = GObject.registerClass({
Signals: {'done': {param_types: [GObject.TYPE_BOOLEAN]}},
}, class AuthenticationDialog extends ModalDialog.ModalDialog {
_init(actionId, description, cookie, userNames) {
super._init({styleClass: 'prompt-dialog'});
this.actionId = actionId;
this.message = description;
this.userNames = userNames;
Main.sessionMode.connectObject('updated', () => {
this.visible = !Main.sessionMode.isLocked;
}, this);
this.connect('closed', this._onDialogClosed.bind(this));
let title = _('Authentication Required');
let headerContent = new Dialog.MessageDialogContent({title, description});
this.contentLayout.add_child(headerContent);
let bodyContent = new Dialog.MessageDialogContent();
if (userNames.length > 1) {
log(`polkitAuthenticationAgent: Received ${userNames.length} ` +
'identities that can be used for authentication. Only ' +
'considering one.');
}
let userName = GLib.get_user_name();
if (!userNames.includes(userName))
userName = 'root';
if (!userNames.includes(userName))
userName = userNames[0];
this._user = AccountsService.UserManager.get_default().get_user(userName);
let userBox = new St.BoxLayout({
style_class: 'polkit-dialog-user-layout',
orientation: Clutter.Orientation.VERTICAL,
});
bodyContent.add_child(userBox);
this._userAvatar = new UserWidget.Avatar(this._user, {
iconSize: DIALOG_ICON_SIZE,
});
this._userAvatar.x_align = Clutter.ActorAlign.CENTER;
userBox.add_child(this._userAvatar);
this._userLabel = new St.Label({
style_class: userName === 'root'
? 'polkit-dialog-user-root-label'
: 'polkit-dialog-user-label',
});
if (userName === 'root')
this._userLabel.text = _('Administrator');
userBox.add_child(this._userLabel);
let passwordBox = new St.BoxLayout({
style_class: 'prompt-dialog-password-layout',
orientation: Clutter.Orientation.VERTICAL,
});
this._passwordEntry = new St.PasswordEntry({
style_class: 'prompt-dialog-password-entry',
text: '',
can_focus: true,
visible: false,
x_align: Clutter.ActorAlign.CENTER,
});
ShellEntry.addContextMenu(this._passwordEntry);
this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this));
this._passwordEntry.bind_property('reactive',
this._passwordEntry.clutter_text, 'editable',
GObject.BindingFlags.SYNC_CREATE);
passwordBox.add_child(this._passwordEntry);
let warningBox = new St.BoxLayout({orientation: Clutter.Orientation.VERTICAL});
let capsLockWarning = new ShellEntry.CapsLockWarning();
this._passwordEntry.bind_property('visible',
capsLockWarning, 'visible',
GObject.BindingFlags.SYNC_CREATE);
warningBox.add_child(capsLockWarning);
this._errorMessageLabel = new St.Label({
style_class: 'prompt-dialog-error-label',
visible: false,
});
this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._errorMessageLabel.clutter_text.line_wrap = true;
warningBox.add_child(this._errorMessageLabel);
this._infoMessageLabel = new St.Label({
style_class: 'prompt-dialog-info-label',
visible: false,
});
this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._infoMessageLabel.clutter_text.line_wrap = true;
warningBox.add_child(this._infoMessageLabel);
/* text is intentionally non-blank otherwise the height is not the same as for
* infoMessage and errorMessageLabel - but it is still invisible because
* gnome-shell.css sets the color to be transparent
*/
this._nullMessageLabel = new St.Label({style_class: 'prompt-dialog-null-label'});
this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._nullMessageLabel.clutter_text.line_wrap = true;
warningBox.add_child(this._nullMessageLabel);
passwordBox.add_child(warningBox);
bodyContent.add_child(passwordBox);
this._cancelButton = this.addButton({
label: _('Cancel'),
action: this.cancel.bind(this),
key: Clutter.KEY_Escape,
});
this._okButton = this.addButton({
label: _('Authenticate'),
action: this._onAuthenticateButtonPressed.bind(this),
reactive: false,
});
this._okButton.bind_property('reactive',
this._okButton, 'can-focus',
GObject.BindingFlags.SYNC_CREATE);
this._passwordEntry.clutter_text.connect('text-changed', text => {
this._okButton.reactive = text.get_text().length > 0;
});
this.contentLayout.add_child(bodyContent);
this._doneEmitted = false;
this._mode = -1;
this._identityToAuth = Polkit.UnixUser.new_for_name(userName);
this._cookie = cookie;
this._user.connectObject(
'notify::is-loaded', this._onUserChanged.bind(this),
'changed', this._onUserChanged.bind(this), this);
this._onUserChanged();
}
_initiateSession() {
this._destroySession(DELAYED_RESET_TIMEOUT);
this._session = new PolkitAgent.Session({
identity: this._identityToAuth,
cookie: this._cookie,
});
this._session.connectObject(
'completed', this._onSessionCompleted.bind(this),
'request', this._onSessionRequest.bind(this),
'show-error', this._onSessionShowError.bind(this),
'show-info', this._onSessionShowInfo.bind(this), this);
this._session.initiate();
}
_ensureOpen() {
// NOTE: ModalDialog.open() is safe to call if the dialog is
// already open - it just returns true without side-effects
if (!this.open()) {
// This can fail if e.g. unable to get input grab
//
// In an ideal world this wouldn't happen (because the
// Shell is in complete control of the session) but that's
// just not how things work right now.
//
// One way to make this happen is by running 'sleep 3;
// pkexec bash' and then opening a popup menu.
//
// We could add retrying if this turns out to be a problem
log('polkitAuthenticationAgent: Failed to show modal dialog. ' +
`Dismissing authentication request for action-id ${this.actionId} ` +
`cookie ${this._cookie}`);
this._emitDone(true);
}
}
_emitDone(dismissed) {
if (!this._doneEmitted) {
this._doneEmitted = true;
this.emit('done', dismissed);
}
}
_onEntryActivate() {
let response = this._passwordEntry.get_text();
if (response.length === 0)
return;
this._passwordEntry.reactive = false;
this._okButton.reactive = false;
this._session.response(response);
// When the user responds, dismiss already shown info and
// error texts (if any)
this._errorMessageLabel.hide();
this._infoMessageLabel.hide();
this._nullMessageLabel.show();
}
_onAuthenticateButtonPressed() {
if (this._mode === DialogMode.CONFIRM)
this._initiateSession();
else
this._onEntryActivate();
}
_onSessionCompleted(session, gainedAuthorization) {
if (this._completed || this._doneEmitted)
return;
this._completed = true;
/* Yay, all done */
if (gainedAuthorization) {
this._emitDone(false);
} else {
/* Unless we are showing an existing error message from the PAM
* module (the PAM module could be reporting the authentication
* error providing authentication-method specific information),
* show "Sorry, that didn't work. Please try again."
*/
if (!this._errorMessageLabel.visible) {
/* Translators: "that didn't work" refers to the fact that the
* requested authentication was not gained; this can happen
* because of an authentication error (like invalid password),
* for instance. */
this._errorMessageLabel.set_text(_('Sorry, that didnt work. Please try again.'));
this._errorMessageLabel.show();
this._infoMessageLabel.hide();
this._nullMessageLabel.hide();
wiggle(this._passwordEntry);
}
/* Try and authenticate again */
this._initiateSession();
}
}
_onSessionRequest(session, request, echoOn) {
if (this._sessionRequestTimeoutId) {
GLib.source_remove(this._sessionRequestTimeoutId);
this._sessionRequestTimeoutId = 0;
}
// Hack: The request string comes directly from PAM, if it's "Password:"
// we replace it with our own to allow localization, if it's something
// else we remove the last colon and any trailing or leading spaces.
if (request === 'Password:' || request === 'Password: ')
this._passwordEntry.hint_text = _('Password');
else
this._passwordEntry.hint_text = request.replace(/: *$/, '').trim();
this._passwordEntry.password_visible = echoOn;
this._passwordEntry.show();
this._passwordEntry.set_text('');
this._passwordEntry.reactive = true;
this._okButton.reactive = false;
this._ensureOpen();
this._passwordEntry.grab_key_focus();
}
_onSessionShowError(session, text) {
this._passwordEntry.set_text('');
this._errorMessageLabel.set_text(text);
this._errorMessageLabel.show();
this._infoMessageLabel.hide();
this._nullMessageLabel.hide();
this._ensureOpen();
}
_onSessionShowInfo(session, text) {
this._passwordEntry.set_text('');
this._infoMessageLabel.set_text(text);
this._infoMessageLabel.show();
this._errorMessageLabel.hide();
this._nullMessageLabel.hide();
this._ensureOpen();
}
_destroySession(delay = 0) {
this._session?.disconnectObject(this);
if (!this._completed)
this._session?.cancel();
this._completed = false;
this._session = null;
if (this._sessionRequestTimeoutId) {
GLib.source_remove(this._sessionRequestTimeoutId);
this._sessionRequestTimeoutId = 0;
}
let resetDialog = () => {
this._sessionRequestTimeoutId = 0;
if (this.state !== ModalDialog.State.OPENED)
return GLib.SOURCE_REMOVE;
this._passwordEntry.hide();
this._cancelButton.grab_key_focus();
this._okButton.reactive = false;
return GLib.SOURCE_REMOVE;
};
if (delay) {
this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog);
GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[gnome-shell] this._sessionRequestTimeoutId');
} else {
resetDialog();
}
}
_onUserChanged() {
if (!this._user.is_loaded)
return;
let userName = this._user.get_user_name();
let realName = this._user.get_real_name();
if (userName !== 'root')
this._userLabel.set_text(realName);
this._userAvatar.update();
if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) {
if (this._mode === DialogMode.CONFIRM)
return;
this._mode = DialogMode.CONFIRM;
this._destroySession();
this._okButton.reactive = true;
/* We normally open the dialog when we get a "request" signal, but
* since in this case initiating a session would perform the
* authentication, only open the dialog and initiate the session
* when the user confirmed. */
this._ensureOpen();
} else {
if (this._mode === DialogMode.AUTH)
return;
this._mode = DialogMode.AUTH;
this._initiateSession();
}
}
close(timestamp) {
// Ensure cleanup if the dialog was never shown
if (this.state === ModalDialog.State.CLOSED)
this._onDialogClosed();
super.close(timestamp);
}
cancel() {
this._emitDone(true);
}
_onDialogClosed() {
Main.sessionMode.disconnectObject(this);
if (this._sessionRequestTimeoutId)
GLib.source_remove(this._sessionRequestTimeoutId);
this._sessionRequestTimeoutId = 0;
this._user?.disconnectObject(this);
this._user = null;
this._destroySession();
}
});
const AuthenticationAgent = GObject.registerClass(
class AuthenticationAgent extends Shell.PolkitAuthenticationAgent {
_init() {
super._init();
this._currentDialog = null;
this.connect('initiate', this._onInitiate.bind(this));
this.connect('cancel', this._onCancel.bind(this));
this._sessionUpdatedId = 0;
}
enable() {
try {
this.register();
} catch {
log('Failed to register AuthenticationAgent');
}
}
disable() {
try {
this.unregister();
} catch {
log('Failed to unregister AuthenticationAgent');
}
}
_onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) {
// Don't pop up a dialog while locked
if (Main.sessionMode.isLocked) {
Main.sessionMode.connectObject('updated', () => {
Main.sessionMode.disconnectObject(this);
this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames);
}, this);
return;
}
this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames);
this._currentDialog.connect('done', this._onDialogDone.bind(this));
}
_onCancel(_nativeAgent) {
this._completeRequest(false);
}
_onDialogDone(_dialog, dismissed) {
this._completeRequest(dismissed);
}
_completeRequest(dismissed) {
this._currentDialog.close();
this._currentDialog = null;
Main.sessionMode.disconnectObject(this);
this.complete(dismissed);
}
});
export {AuthenticationAgent as Component};

195
js/ui/ctrlAltTab.js Normal file
View file

@ -0,0 +1,195 @@
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Main from './main.js';
import * as SwitcherPopup from './switcherPopup.js';
import * as Params from '../misc/params.js';
const POPUP_APPICON_SIZE = 96;
export const SortGroup = {
TOP: 0,
MIDDLE: 1,
BOTTOM: 2,
};
export class CtrlAltTabManager {
constructor() {
this._items = [];
this.addGroup(global.window_group,
_('Windows'),
'shell-focus-windows-symbolic', {
sortGroup: SortGroup.TOP,
focusCallback: this._focusWindows.bind(this),
});
}
addGroup(root, name, icon, params) {
const item = Params.parse(params, {
sortGroup: SortGroup.MIDDLE,
proxy: root,
focusCallback: null,
});
item.root = root;
item.name = name;
item.iconName = icon;
this._items.push(item);
root.connect('destroy', () => this.removeGroup(root));
if (root instanceof St.Widget)
global.focus_manager.add_group(root);
}
removeGroup(root) {
if (root instanceof St.Widget)
global.focus_manager.remove_group(root);
for (let i = 0; i < this._items.length; i++) {
if (this._items[i].root === root) {
this._items.splice(i, 1);
return;
}
}
}
focusGroup(item, timestamp) {
if (item.focusCallback)
item.focusCallback(timestamp);
else
item.root.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
}
// Sort the items into a consistent order; panel first, tray last,
// and everything else in between, sorted by X coordinate, so that
// they will have the same left-to-right ordering in the
// Ctrl-Alt-Tab dialog as they do onscreen.
_sortItems(a, b) {
if (a.sortGroup !== b.sortGroup)
return a.sortGroup - b.sortGroup;
let [ax] = a.proxy.get_transformed_position();
let [bx] = b.proxy.get_transformed_position();
return ax - bx;
}
popup(backward, binding, mask) {
// Start with the set of focus groups that are currently mapped
let items = this._items.filter(item => item.proxy.mapped);
// And add the windows metacity would show in its Ctrl-Alt-Tab list
if (Main.sessionMode.hasWindows && !Main.overview.visible) {
let display = global.display;
let workspaceManager = global.workspace_manager;
let activeWorkspace = workspaceManager.get_active_workspace();
let windows = display.get_tab_list(Meta.TabList.DOCKS,
activeWorkspace);
let windowTracker = Shell.WindowTracker.get_default();
for (let i = 0; i < windows.length; i++) {
let icon = null;
let iconName = null;
if (windows[i].get_window_type() === Meta.WindowType.DESKTOP) {
iconName = 'shell-focus-desktop-symbolic';
} else {
const app = windowTracker.get_window_app(windows[i]);
icon = app.create_icon_texture(POPUP_APPICON_SIZE);
}
items.push({
name: windows[i].title,
proxy: windows[i].get_compositor_private(),
focusCallback: timestamp => {
Main.activateWindow(windows[i], timestamp);
},
iconActor: icon,
iconName,
sortGroup: SortGroup.MIDDLE,
});
}
}
if (!items.length)
return;
items.sort(this._sortItems.bind(this));
if (!this._popup) {
this._popup = new CtrlAltTabPopup(items);
this._popup.show(backward, binding, mask);
this._popup.connect('destroy', () => {
this._popup = null;
});
}
}
_focusWindows(timestamp) {
global.display.focus_default_window(timestamp);
}
}
const CtrlAltTabPopup = GObject.registerClass(
class CtrlAltTabPopup extends SwitcherPopup.SwitcherPopup {
_init(items) {
super._init(items);
this._switcherList = new CtrlAltTabSwitcher(this._items);
}
_keyPressHandler(keysym, action) {
if (action === Meta.KeyBindingAction.SWITCH_PANELS)
this._select(this._next());
else if (action === Meta.KeyBindingAction.SWITCH_PANELS_BACKWARD)
this._select(this._previous());
else if (keysym === Clutter.KEY_Left)
this._select(this._previous());
else if (keysym === Clutter.KEY_Right)
this._select(this._next());
else
return Clutter.EVENT_PROPAGATE;
return Clutter.EVENT_STOP;
}
_finish(time) {
super._finish(time);
Main.ctrlAltTabManager.focusGroup(this._items[this._selectedIndex], time);
}
});
const CtrlAltTabSwitcher = GObject.registerClass(
class CtrlAltTabSwitcher extends SwitcherPopup.SwitcherList {
_init(items) {
super._init(true);
for (let i = 0; i < items.length; i++)
this._addIcon(items[i]);
}
_addIcon(item) {
const box = new St.BoxLayout({
style_class: 'alt-tab-app',
orientation: Clutter.Orientation.VERTICAL,
});
let icon = item.iconActor;
if (!icon) {
icon = new St.Icon({
icon_name: item.iconName,
icon_size: POPUP_APPICON_SIZE,
});
}
box.add_child(icon);
let text = new St.Label({
text: item.name,
x_align: Clutter.ActorAlign.CENTER,
});
box.add_child(text);
this.addItem(box, text);
}
});

999
js/ui/dash.js Normal file
View file

@ -0,0 +1,999 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Graphene from 'gi://Graphene';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as AppDisplay from './appDisplay.js';
import * as AppFavorites from './appFavorites.js';
import * as DND from './dnd.js';
import * as IconGrid from './iconGrid.js';
import * as Main from './main.js';
import * as Overview from './overview.js';
const DASH_ANIMATION_TIME = 200;
const DASH_ITEM_LABEL_SHOW_TIME = 150;
const DASH_ITEM_LABEL_HIDE_TIME = 100;
const DASH_ITEM_HOVER_TIMEOUT = 300;
export const DashIcon = GObject.registerClass(
class DashIcon extends AppDisplay.AppIcon {
_init(app) {
super._init(app, {
setSizeManually: true,
showLabel: false,
});
}
popupMenu() {
super.popupMenu(St.Side.BOTTOM);
}
// Disable scale-n-fade methods used during DND by parent
scaleAndFade() {
}
undoScaleAndFade() {
}
handleDragOver() {
return DND.DragMotionResult.CONTINUE;
}
acceptDrop() {
return false;
}
});
// A container like StBin, but taking the child's scale into account
// when requesting a size
export const DashItemContainer = GObject.registerClass(
class DashItemContainer extends St.Widget {
_init() {
super._init({
style_class: 'dash-item-container',
pivot_point: new Graphene.Point({x: .5, y: .5}),
layout_manager: new Clutter.BinLayout(),
scale_x: 0,
scale_y: 0,
opacity: 0,
x_expand: true,
x_align: Clutter.ActorAlign.CENTER,
});
this._labelText = '';
this.label = new St.Label({style_class: 'dash-label'});
this.label.hide();
Main.layoutManager.addChrome(this.label);
this.label.connectObject('destroy', () => (this.label = null), this);
this.label_actor = this.label;
this.child = null;
this.animatingOut = false;
this.connect('notify::scale-x', () => this.queue_relayout());
this.connect('notify::scale-y', () => this.queue_relayout());
this.connect('destroy', () => {
if (this.child != null)
this.child.destroy();
this.label?.destroy();
});
}
vfunc_get_preferred_height(forWidth) {
let themeNode = this.get_theme_node();
forWidth = themeNode.adjust_for_width(forWidth);
let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth);
return themeNode.adjust_preferred_height(
minHeight * this.scale_y,
natHeight * this.scale_y);
}
vfunc_get_preferred_width(forHeight) {
let themeNode = this.get_theme_node();
forHeight = themeNode.adjust_for_height(forHeight);
let [minWidth, natWidth] = super.vfunc_get_preferred_width(forHeight);
return themeNode.adjust_preferred_width(
minWidth * this.scale_x,
natWidth * this.scale_x);
}
showLabel() {
if (!this._labelText)
return;
this.label.set_text(this._labelText);
this.label.opacity = 0;
this.label.show();
let [stageX, stageY] = this.get_transformed_position();
const itemWidth = this.allocation.get_width();
const labelWidth = this.label.get_width();
const xOffset = Math.floor((itemWidth - labelWidth) / 2);
const x = Math.clamp(stageX + xOffset, 0, global.stage.width - labelWidth);
let node = this.label.get_theme_node();
const yOffset = node.get_length('-y-offset');
const y = stageY - this.label.height - yOffset;
this.label.set_position(x, y);
this.label.ease({
opacity: 255,
duration: DASH_ITEM_LABEL_SHOW_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
setLabelText(text) {
this._labelText = text;
this.child.accessible_name = text;
}
hideLabel() {
this.label.ease({
opacity: 0,
duration: DASH_ITEM_LABEL_HIDE_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => this.label.hide(),
});
}
setChild(actor) {
if (this.child === actor)
return;
this.destroy_all_children();
this.child = actor;
this.child.y_expand = true;
this.add_child(this.child);
}
show(animate) {
if (this.child == null)
return;
let time = animate ? DASH_ANIMATION_TIME : 0;
this.ease({
scale_x: 1,
scale_y: 1,
opacity: 255,
duration: time,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
animateOutAndDestroy() {
this.label.hide();
if (this.child == null) {
this.destroy();
return;
}
this.animatingOut = true;
this.ease({
scale_x: 0,
scale_y: 0,
opacity: 0,
duration: DASH_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => this.destroy(),
});
}
});
export const ShowAppsIcon = GObject.registerClass(
class ShowAppsIcon extends DashItemContainer {
_init() {
super._init();
this.toggleButton = new St.Button({
style_class: 'show-apps',
track_hover: true,
can_focus: true,
toggle_mode: true,
});
this._iconActor = null;
this.icon = new IconGrid.BaseIcon(_('Show Apps'), {
setSizeManually: true,
showLabel: false,
createIcon: this._createIcon.bind(this),
});
this.icon.y_align = Clutter.ActorAlign.CENTER;
this.toggleButton.child = this.icon;
this.toggleButton._delegate = this;
this.setChild(this.toggleButton);
this.setDragApp(null);
}
_createIcon(size) {
this._iconActor = new St.Icon({
icon_name: 'view-app-grid-symbolic',
icon_size: size,
style_class: 'show-apps-icon',
track_hover: true,
});
return this._iconActor;
}
_canRemoveApp(app) {
if (app == null)
return false;
if (!global.settings.is_writable('favorite-apps'))
return false;
let id = app.get_id();
let isFavorite = AppFavorites.getAppFavorites().isFavorite(id);
return isFavorite;
}
setDragApp(app) {
let canRemove = this._canRemoveApp(app);
this.toggleButton.set_hover(canRemove);
if (this._iconActor)
this._iconActor.set_hover(canRemove);
if (canRemove)
this.setLabelText(_('Unpin'));
else
this.setLabelText(_('Show Apps'));
}
handleDragOver(source, _actor, _x, _y, _time) {
if (!this._canRemoveApp(Dash.getAppFromSource(source)))
return DND.DragMotionResult.NO_DROP;
return DND.DragMotionResult.MOVE_DROP;
}
acceptDrop(source, _actor, _x, _y, _time) {
const app = Dash.getAppFromSource(source);
if (!this._canRemoveApp(app))
return false;
let id = app.get_id();
const laters = global.compositor.get_laters();
laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
AppFavorites.getAppFavorites().removeFavorite(id);
return false;
});
return true;
}
});
const DragPlaceholderItem = GObject.registerClass(
class DragPlaceholderItem extends DashItemContainer {
_init() {
super._init();
this.setChild(new St.Bin({style_class: 'placeholder'}));
}
});
const EmptyDropTargetItem = GObject.registerClass(
class EmptyDropTargetItem extends DashItemContainer {
_init() {
super._init();
this.setChild(new St.Bin({style_class: 'empty-dash-drop-target'}));
}
});
const DashIconsLayout = GObject.registerClass(
class DashIconsLayout extends Clutter.BoxLayout {
_init() {
super._init({
orientation: Clutter.Orientation.HORIZONTAL,
});
}
vfunc_get_preferred_width(container, forHeight) {
const [, natWidth] = super.vfunc_get_preferred_width(container, forHeight);
return [0, natWidth];
}
});
const baseIconSizes = [16, 22, 24, 32, 48, 64];
export const Dash = GObject.registerClass({
Signals: {'icon-size-changed': {}},
}, class Dash extends St.Widget {
/**
* @param {object} source
*/
static getAppFromSource(source) {
if (source instanceof AppDisplay.AppIcon)
return source.app;
else
return null;
}
_init() {
this._maxWidth = -1;
this._maxHeight = -1;
this.iconSize = 64;
this._shownInitially = false;
this._separator = null;
this._dragPlaceholder = null;
this._dragPlaceholderPos = -1;
this._animatingPlaceholdersCount = 0;
this._showLabelTimeoutId = 0;
this._resetHoverTimeoutId = 0;
this._labelShowing = false;
super._init({
name: 'dash',
offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS,
layout_manager: new Clutter.BinLayout(),
});
this._dashContainer = new St.BoxLayout({
x_align: Clutter.ActorAlign.CENTER,
y_expand: true,
});
this._box = new St.Widget({
clip_to_allocation: true,
layout_manager: new DashIconsLayout(),
y_expand: true,
});
this._box._delegate = this;
this._dashContainer.add_child(this._box);
this._showAppsIcon = new ShowAppsIcon();
this._showAppsIcon.show(false);
this._showAppsIcon.icon.setIconSize(this.iconSize);
this._hookUpLabel(this._showAppsIcon);
this._dashContainer.add_child(this._showAppsIcon);
this.showAppsButton = this._showAppsIcon.toggleButton;
this._background = new St.Widget({
style_class: 'dash-background',
});
const sizerBox = new Clutter.Actor();
sizerBox.add_constraint(new Clutter.BindConstraint({
source: this._showAppsIcon.icon,
coordinate: Clutter.BindCoordinate.HEIGHT,
}));
sizerBox.add_constraint(new Clutter.BindConstraint({
source: this._dashContainer,
coordinate: Clutter.BindCoordinate.WIDTH,
}));
this._background.add_child(sizerBox);
this.add_child(this._background);
this.add_child(this._dashContainer);
this._workId = Main.initializeDeferredWork(this._box, this._redisplay.bind(this));
this._appSystem = Shell.AppSystem.get_default();
this._appSystem.connect('installed-changed', () => {
AppFavorites.getAppFavorites().reload();
this._queueRedisplay();
});
AppFavorites.getAppFavorites().connect('changed', this._queueRedisplay.bind(this));
this._appSystem.connect('app-state-changed', this._queueRedisplay.bind(this));
Main.overview.connect('item-drag-begin',
this._onItemDragBegin.bind(this));
Main.overview.connect('item-drag-end',
this._onItemDragEnd.bind(this));
Main.overview.connect('item-drag-cancelled',
this._onItemDragCancelled.bind(this));
Main.overview.connect('window-drag-begin',
this._onWindowDragBegin.bind(this));
Main.overview.connect('window-drag-cancelled',
this._onWindowDragEnd.bind(this));
Main.overview.connect('window-drag-end',
this._onWindowDragEnd.bind(this));
// Translators: this is the name of the dock/favorites area on
// the bottom of the overview
Main.ctrlAltTabManager.addGroup(this, _('Dash'), 'shell-focus-dash-symbolic');
}
_onItemDragBegin() {
this._dragCancelled = false;
this._dragMonitor = {
dragMotion: this._onItemDragMotion.bind(this),
};
DND.addDragMonitor(this._dragMonitor);
if (this._box.get_n_children() === 0) {
this._emptyDropTarget = new EmptyDropTargetItem();
this._box.insert_child_at_index(this._emptyDropTarget, 0);
this._emptyDropTarget.show(true);
}
}
_onItemDragCancelled() {
this._dragCancelled = true;
this._endItemDrag();
}
_onItemDragEnd() {
if (this._dragCancelled)
return;
this._endItemDrag();
}
_endItemDrag() {
this._clearDragPlaceholder();
this._clearEmptyDropTarget();
this._showAppsIcon.setDragApp(null);
DND.removeDragMonitor(this._dragMonitor);
}
_onItemDragMotion(dragEvent) {
const app = Dash.getAppFromSource(dragEvent.source);
if (app == null)
return DND.DragMotionResult.CONTINUE;
let showAppsHovered =
this._showAppsIcon.contains(dragEvent.targetActor);
if (!this._box.contains(dragEvent.targetActor) || showAppsHovered)
this._clearDragPlaceholder();
if (showAppsHovered)
this._showAppsIcon.setDragApp(app);
else
this._showAppsIcon.setDragApp(null);
return DND.DragMotionResult.CONTINUE;
}
_onWindowDragBegin() {
this.ease({
opacity: 128,
duration: Overview.ANIMATION_TIME / 2,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
_onWindowDragEnd() {
this.ease({
opacity: 255,
duration: Overview.ANIMATION_TIME / 2,
mode: Clutter.AnimationMode.EASE_IN_QUAD,
});
}
_appIdListToHash(apps) {
let ids = {};
for (let i = 0; i < apps.length; i++)
ids[apps[i].get_id()] = apps[i];
return ids;
}
_queueRedisplay() {
Main.queueDeferredWork(this._workId);
}
_hookUpLabel(item, appIcon) {
item.child.connect('notify::hover', () => {
this._syncLabel(item, appIcon);
});
item.child.connect('clicked', () => {
this._labelShowing = false;
item.hideLabel();
});
Main.overview.connectObject('hiding', () => {
this._labelShowing = false;
item.hideLabel();
}, item.child);
if (appIcon) {
appIcon.connect('sync-tooltip', () => {
this._syncLabel(item, appIcon);
});
}
}
_createAppItem(app) {
let item = new DashItemContainer();
let appIcon = new DashIcon(app);
appIcon.connect('menu-state-changed', (o, opened) => {
this._itemMenuStateChanged(item, opened);
});
item.setChild(appIcon);
// Override default AppIcon label_actor, now the
// accessible_name is set at DashItemContainer.setLabelText
appIcon.label_actor = null;
item.setLabelText(app.get_name());
appIcon.icon.setIconSize(this.iconSize);
this._hookUpLabel(item, appIcon);
return item;
}
_itemMenuStateChanged(item, opened) {
// When the menu closes, it calls sync_hover, which means
// that the notify::hover handler does everything we need to.
if (opened) {
if (this._showLabelTimeoutId > 0) {
GLib.source_remove(this._showLabelTimeoutId);
this._showLabelTimeoutId = 0;
}
item.hideLabel();
}
}
_syncLabel(item, appIcon) {
let shouldShow = appIcon ? appIcon.shouldShowTooltip() : item.child.get_hover();
if (shouldShow) {
if (this._showLabelTimeoutId === 0) {
let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT;
this._showLabelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout,
() => {
this._labelShowing = true;
item.showLabel();
this._showLabelTimeoutId = 0;
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._showLabelTimeoutId, '[gnome-shell] item.showLabel');
if (this._resetHoverTimeoutId > 0) {
GLib.source_remove(this._resetHoverTimeoutId);
this._resetHoverTimeoutId = 0;
}
}
} else {
if (this._showLabelTimeoutId > 0)
GLib.source_remove(this._showLabelTimeoutId);
this._showLabelTimeoutId = 0;
item.hideLabel();
if (this._labelShowing) {
this._resetHoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DASH_ITEM_HOVER_TIMEOUT,
() => {
this._labelShowing = false;
this._resetHoverTimeoutId = 0;
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._resetHoverTimeoutId, '[gnome-shell] this._labelShowing');
}
}
}
_adjustIconSize() {
// For the icon size, we only consider children which are "proper"
// icons (i.e. ignoring drag placeholders) and which are not
// animating out (which means they will be destroyed at the end of
// the animation)
let iconChildren = this._box.get_children().filter(actor => {
return actor.child &&
actor.child._delegate &&
actor.child._delegate.icon &&
!actor.animatingOut;
});
iconChildren.push(this._showAppsIcon);
if (this._maxWidth === -1 || this._maxHeight === -1)
return;
const themeNode = this.get_theme_node();
const maxAllocation = new Clutter.ActorBox({
x1: 0,
y1: 0,
x2: this._maxWidth,
y2: 42, /* whatever */
});
let maxContent = themeNode.get_content_box(maxAllocation);
let availWidth = maxContent.x2 - maxContent.x1;
let spacing = themeNode.get_length('spacing');
let firstButton = iconChildren[0].child;
let firstIcon = firstButton._delegate.icon;
// Enforce valid spacings during the size request
firstIcon.icon.ensure_style();
const [, , iconWidth, iconHeight] = firstIcon.icon.get_preferred_size();
const [, , buttonWidth, buttonHeight] = firstButton.get_preferred_size();
// Subtract icon padding and box spacing from the available width
availWidth -= iconChildren.length * (buttonWidth - iconWidth) +
(iconChildren.length - 1) * spacing;
let availHeight = this._maxHeight;
availHeight -= this.margin_top + this.margin_bottom;
availHeight -= this._background.get_theme_node().get_vertical_padding();
availHeight -= themeNode.get_vertical_padding();
availHeight -= buttonHeight - iconHeight;
const maxIconSize = Math.min(availWidth / iconChildren.length, availHeight);
let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
let iconSizes = baseIconSizes.map(s => s * scaleFactor);
let newIconSize = baseIconSizes[0];
for (let i = 0; i < iconSizes.length; i++) {
if (iconSizes[i] <= maxIconSize)
newIconSize = baseIconSizes[i];
}
if (newIconSize === this.iconSize)
return;
let oldIconSize = this.iconSize;
this.iconSize = newIconSize;
this.emit('icon-size-changed');
let scale = oldIconSize / newIconSize;
for (let i = 0; i < iconChildren.length; i++) {
let icon = iconChildren[i].child._delegate.icon;
// Set the new size immediately, to keep the icons' sizes
// in sync with this.iconSize
icon.setIconSize(this.iconSize);
// Don't animate the icon size change when the overview
// is transitioning, not visible or when initially filling
// the dash
if (!Main.overview.visible || Main.overview.animationInProgress ||
!this._shownInitially)
continue;
let [targetWidth, targetHeight] = icon.icon.get_size();
// Scale the icon's texture to the previous size and
// tween to the new size
icon.icon.set_size(
icon.icon.width * scale,
icon.icon.height * scale);
icon.icon.ease({
width: targetWidth,
height: targetHeight,
duration: DASH_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
if (this._separator) {
this._separator.ease({
height: this.iconSize,
duration: DASH_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
});
}
}
_redisplay() {
let favorites = AppFavorites.getAppFavorites().getFavoriteMap();
let running = this._appSystem.get_running();
let children = this._box.get_children().filter(actor => {
return actor.child &&
actor.child._delegate &&
actor.child._delegate.app;
});
// Apps currently in the dash
let oldApps = children.map(actor => actor.child._delegate.app);
// Apps supposed to be in the dash
let newApps = [];
for (let id in favorites)
newApps.push(favorites[id]);
for (let i = 0; i < running.length; i++) {
let app = running[i];
if (app.get_id() in favorites)
continue;
newApps.push(app);
}
// Figure out the actual changes to the list of items; we iterate
// over both the list of items currently in the dash and the list
// of items expected there, and collect additions and removals.
// Moves are both an addition and a removal, where the order of
// the operations depends on whether we encounter the position
// where the item has been added first or the one from where it
// was removed.
// There is an assumption that only one item is moved at a given
// time; when moving several items at once, everything will still
// end up at the right position, but there might be additional
// additions/removals (e.g. it might remove all the launchers
// and add them back in the new order even if a smaller set of
// additions and removals is possible).
// If above assumptions turns out to be a problem, we might need
// to use a more sophisticated algorithm, e.g. Longest Common
// Subsequence as used by diff.
let addedItems = [];
let removedActors = [];
let newIndex = 0;
let oldIndex = 0;
while (newIndex < newApps.length || oldIndex < oldApps.length) {
let oldApp = oldApps.length > oldIndex ? oldApps[oldIndex] : null;
let newApp = newApps.length > newIndex ? newApps[newIndex] : null;
// No change at oldIndex/newIndex
if (oldApp === newApp) {
oldIndex++;
newIndex++;
continue;
}
// App removed at oldIndex
if (oldApp && !newApps.includes(oldApp)) {
removedActors.push(children[oldIndex]);
oldIndex++;
continue;
}
// App added at newIndex
if (newApp && !oldApps.includes(newApp)) {
addedItems.push({
app: newApp,
item: this._createAppItem(newApp),
pos: newIndex,
});
newIndex++;
continue;
}
// App moved
let nextApp = newApps.length > newIndex + 1
? newApps[newIndex + 1] : null;
let insertHere = nextApp && nextApp === oldApp;
let alreadyRemoved = removedActors.reduce((result, actor) => {
let removedApp = actor.child._delegate.app;
return result || removedApp === newApp;
}, false);
if (insertHere || alreadyRemoved) {
let newItem = this._createAppItem(newApp);
addedItems.push({
app: newApp,
item: newItem,
pos: newIndex + removedActors.length,
});
newIndex++;
} else {
removedActors.push(children[oldIndex]);
oldIndex++;
}
}
for (let i = 0; i < addedItems.length; i++) {
this._box.insert_child_at_index(
addedItems[i].item,
addedItems[i].pos);
}
for (let i = 0; i < removedActors.length; i++) {
let item = removedActors[i];
// Don't animate item removal when the overview is transitioning
// or hidden
if (Main.overview.visible && !Main.overview.animationInProgress)
item.animateOutAndDestroy();
else
item.destroy();
}
this._adjustIconSize();
// Skip animations on first run when adding the initial set
// of items, to avoid all items zooming in at once
let animate = this._shownInitially && Main.overview.visible &&
!Main.overview.animationInProgress;
if (!this._shownInitially)
this._shownInitially = true;
for (let i = 0; i < addedItems.length; i++)
addedItems[i].item.show(animate);
// Update separator
const nFavorites = Object.keys(favorites).length;
const nIcons = children.length + addedItems.length - removedActors.length;
if (nFavorites > 0 && nFavorites < nIcons) {
if (!this._separator) {
this._separator = new St.Widget({
style_class: 'dash-separator',
y_align: Clutter.ActorAlign.CENTER,
height: this.iconSize,
});
this._box.add_child(this._separator);
}
let pos = nFavorites + this._animatingPlaceholdersCount;
if (this._dragPlaceholder)
pos++;
this._box.set_child_at_index(this._separator, pos);
} else if (this._separator) {
this._separator.destroy();
this._separator = null;
}
// Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744
// Without it, StBoxLayout may use a stale size cache
this._box.queue_relayout();
}
_clearDragPlaceholder() {
if (this._dragPlaceholder) {
this._animatingPlaceholdersCount++;
this._dragPlaceholder.connect('destroy', () => {
this._animatingPlaceholdersCount--;
});
this._dragPlaceholder.animateOutAndDestroy();
this._dragPlaceholder = null;
}
this._dragPlaceholderPos = -1;
}
_clearEmptyDropTarget() {
if (this._emptyDropTarget) {
this._emptyDropTarget.animateOutAndDestroy();
this._emptyDropTarget = null;
}
}
handleDragOver(source, actor, x, _y, _time) {
const app = Dash.getAppFromSource(source);
// Don't allow favoriting of transient apps
if (app == null || app.is_window_backed())
return DND.DragMotionResult.NO_DROP;
if (!global.settings.is_writable('favorite-apps'))
return DND.DragMotionResult.NO_DROP;
let favorites = AppFavorites.getAppFavorites().getFavorites();
let numFavorites = favorites.length;
let favPos = favorites.indexOf(app);
let children = this._box.get_children();
let numChildren = children.length;
let boxWidth = this._box.width;
// Keep the placeholder out of the index calculation; assuming that
// the remove target has the same size as "normal" items, we don't
// need to do the same adjustment there.
if (this._dragPlaceholder) {
boxWidth -= this._dragPlaceholder.width;
numChildren--;
}
// Same with the separator
if (this._separator) {
boxWidth -= this._separator.width;
numChildren--;
}
let pos;
if (this._emptyDropTarget)
pos = 0; // always insert at the start when dash is empty
else if (this.text_direction === Clutter.TextDirection.RTL)
pos = numChildren - Math.floor(x * numChildren / boxWidth);
else
pos = Math.floor(x * numChildren / boxWidth);
// Put the placeholder after the last favorite if we are not
// in the favorites zone
if (pos > numFavorites)
pos = numFavorites;
if (pos !== this._dragPlaceholderPos && this._animatingPlaceholdersCount === 0) {
this._dragPlaceholderPos = pos;
// Don't allow positioning before or after self
if (favPos !== -1 && (pos === favPos || pos === favPos + 1)) {
this._clearDragPlaceholder();
return DND.DragMotionResult.CONTINUE;
}
// If the placeholder already exists, we just move
// it, but if we are adding it, expand its size in
// an animation
let fadeIn;
if (this._dragPlaceholder) {
this._dragPlaceholder.destroy();
fadeIn = false;
} else {
fadeIn = true;
}
this._dragPlaceholder = new DragPlaceholderItem();
this._dragPlaceholder.child.set_width(this.iconSize);
this._dragPlaceholder.child.set_height(this.iconSize / 2);
this._box.insert_child_at_index(
this._dragPlaceholder,
this._dragPlaceholderPos);
this._dragPlaceholder.show(fadeIn);
}
if (!this._dragPlaceholder)
return DND.DragMotionResult.NO_DROP;
return DND.DragMotionResult.MOVE_DROP;
}
// Draggable target interface
acceptDrop(source, _actor, _x, _y, _time) {
const app = Dash.getAppFromSource(source);
// Don't allow favoriting of transient apps
if (app == null || app.is_window_backed())
return false;
if (!global.settings.is_writable('favorite-apps'))
return false;
let id = app.get_id();
let favorites = AppFavorites.getAppFavorites().getFavoriteMap();
let srcIsFavorite = id in favorites;
let favPos = 0;
let children = this._box.get_children();
for (let i = 0; i < this._dragPlaceholderPos; i++) {
if (this._dragPlaceholder &&
children[i] === this._dragPlaceholder)
continue;
let childId = children[i].child._delegate.app.get_id();
if (childId === id)
continue;
if (childId in favorites)
favPos++;
}
// No drag placeholder means we don't want to favorite the app
// and we are dragging it to its original position
if (!this._dragPlaceholder)
return true;
const laters = global.compositor.get_laters();
laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
let appFavorites = AppFavorites.getAppFavorites();
if (srcIsFavorite)
appFavorites.moveFavoriteToPos(id, favPos);
else
appFavorites.addFavoriteAtPos(id, favPos);
return false;
});
return true;
}
setMaxSize(maxWidth, maxHeight) {
if (this._maxWidth === maxWidth &&
this._maxHeight === maxHeight)
return;
this._maxWidth = maxWidth;
this._maxHeight = maxHeight;
this._queueRedisplay();
}
});

1018
js/ui/dateMenu.js Normal file

File diff suppressed because it is too large Load diff

371
js/ui/dialog.js Normal file
View file

@ -0,0 +1,371 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Pango from 'gi://Pango';
import St from 'gi://St';
function _setLabel(label, value) {
label.set({
text: value || '',
visible: value !== null,
});
}
export const Dialog = GObject.registerClass(
class Dialog extends St.Widget {
_init(parentActor, styleClass) {
super._init({
layout_manager: new Clutter.BinLayout(),
reactive: true,
});
this.connect('destroy', this._onDestroy.bind(this));
this._initialKeyFocus = null;
this._pressedKey = null;
this._buttonKeys = {};
this._createDialog();
this.add_child(this._dialog);
if (styleClass != null)
this._dialog.add_style_class_name(styleClass);
this._parentActor = parentActor;
this._parentActor.add_child(this);
}
_createDialog() {
this._dialog = new St.BoxLayout({
style_class: 'modal-dialog',
x_align: Clutter.ActorAlign.CENTER,
y_align: Clutter.ActorAlign.CENTER,
orientation: Clutter.Orientation.VERTICAL,
});
// modal dialogs are fixed width and grow vertically; set the request
// mode accordingly so wrapped labels are handled correctly during
// size requests.
this._dialog.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH;
this._dialog.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
this.contentLayout = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
style_class: 'modal-dialog-content-box',
y_expand: true,
});
this._dialog.add_child(this.contentLayout);
this.buttonLayout = new St.Widget({
style_class: 'modal-dialog-button-box',
layout_manager: new Clutter.BoxLayout({
spacing: 12,
homogeneous: true,
}),
});
this._dialog.add_child(this.buttonLayout);
}
makeInactive() {
this.buttonLayout.get_children().forEach(c => c.set_reactive(false));
}
_onDestroy() {
this.makeInactive();
}
vfunc_event(event) {
if (event.type() === Clutter.EventType.KEY_PRESS) {
this._pressedKey = event.get_key_symbol();
} else if (event.type() === Clutter.EventType.KEY_RELEASE) {
let pressedKey = this._pressedKey;
this._pressedKey = null;
let symbol = event.get_key_symbol();
if (symbol !== pressedKey)
return Clutter.EVENT_PROPAGATE;
let buttonInfo = this._buttonKeys[symbol];
if (!buttonInfo)
return Clutter.EVENT_PROPAGATE;
let {button, action} = buttonInfo;
if (action && button.reactive) {
action();
return Clutter.EVENT_STOP;
}
}
return Clutter.EVENT_PROPAGATE;
}
_setInitialKeyFocus(actor) {
this._initialKeyFocus?.disconnectObject(this);
this._initialKeyFocus = actor;
actor.connectObject('destroy',
() => (this._initialKeyFocus = null), this);
}
get initialKeyFocus() {
return this._initialKeyFocus || this;
}
addButton(buttonInfo) {
let {label, action, key} = buttonInfo;
let isDefault = buttonInfo['default'];
let keys;
if (key)
keys = [key];
else if (isDefault)
keys = [Clutter.KEY_Return, Clutter.KEY_KP_Enter, Clutter.KEY_ISO_Enter];
else
keys = [];
let button = new St.Button({
style_class: 'modal-dialog-button',
button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
reactive: true,
can_focus: true,
x_expand: true,
y_expand: true,
label,
});
button.connect('clicked', () => action());
buttonInfo['button'] = button;
if (isDefault)
button.add_style_pseudo_class('default');
if (this._initialKeyFocus == null || isDefault)
this._setInitialKeyFocus(button);
for (let i in keys)
this._buttonKeys[keys[i]] = buttonInfo;
this.buttonLayout.add_child(button);
return button;
}
clearButtons() {
this.buttonLayout.destroy_all_children();
this._buttonKeys = {};
}
});
export const MessageDialogContent = GObject.registerClass({
Properties: {
'title': GObject.ParamSpec.string(
'title', null, null,
GObject.ParamFlags.READWRITE |
GObject.ParamFlags.CONSTRUCT,
null),
'description': GObject.ParamSpec.string(
'description', null, null,
GObject.ParamFlags.READWRITE |
GObject.ParamFlags.CONSTRUCT,
null),
},
}, class MessageDialogContent extends St.BoxLayout {
_init(params) {
this._title = new St.Label({style_class: 'message-dialog-title'});
this._description = new St.Label({style_class: 'message-dialog-description'});
this._title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._title.clutter_text.line_wrap = true;
this._description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._description.clutter_text.line_wrap = true;
super._init({
style_class: 'message-dialog-content',
x_expand: true,
orientation: Clutter.Orientation.VERTICAL,
...params,
});
this.connect('notify::size', this._updateTitleStyle.bind(this));
this.connect('destroy', this._onDestroy.bind(this));
this.add_child(this._title);
this.add_child(this._description);
}
_onDestroy() {
if (this._updateTitleStyleLater) {
const laters = global.compositor.get_laters();
laters.remove(this._updateTitleStyleLater);
delete this._updateTitleStyleLater;
}
}
get title() {
return this._title.text;
}
get description() {
return this._description.text;
}
_updateTitleStyle() {
if (!this._title.mapped)
return;
this._title.ensure_style();
const [, titleNatWidth] = this._title.get_preferred_width(-1);
if (titleNatWidth > this.width) {
if (this._updateTitleStyleLater)
return;
const laters = global.compositor.get_laters();
this._updateTitleStyleLater = laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
this._updateTitleStyleLater = 0;
this._title.add_style_class_name('lightweight');
return GLib.SOURCE_REMOVE;
});
}
}
set title(title) {
if (this._title.text === title)
return;
_setLabel(this._title, title);
this._title.remove_style_class_name('lightweight');
this._updateTitleStyle();
this.notify('title');
}
set description(description) {
if (this._description.text === description)
return;
_setLabel(this._description, description);
this.notify('description');
}
});
export const ListSection = GObject.registerClass({
Properties: {
'title': GObject.ParamSpec.string(
'title', null, null,
GObject.ParamFlags.READWRITE |
GObject.ParamFlags.CONSTRUCT,
null),
},
}, class ListSection extends St.BoxLayout {
_init(params) {
this._title = new St.Label({style_class: 'dialog-list-title'});
this.list = new St.BoxLayout({
style_class: 'dialog-list-box',
orientation: Clutter.Orientation.VERTICAL,
});
this._listScrollView = new St.ScrollView({
style_class: 'dialog-list-scrollview',
child: this.list,
});
super._init({
style_class: 'dialog-list',
x_expand: true,
orientation: Clutter.Orientation.VERTICAL,
...params,
});
this.label_actor = this._title;
this.add_child(this._title);
this.add_child(this._listScrollView);
}
get title() {
return this._title.text;
}
set title(title) {
_setLabel(this._title, title);
this.notify('title');
}
});
export const ListSectionItem = GObject.registerClass({
Properties: {
'icon-actor': GObject.ParamSpec.object(
'icon-actor', null, null,
GObject.ParamFlags.READWRITE,
Clutter.Actor.$gtype),
'title': GObject.ParamSpec.string(
'title', null, null,
GObject.ParamFlags.READWRITE |
GObject.ParamFlags.CONSTRUCT,
null),
'description': GObject.ParamSpec.string(
'description', null, null,
GObject.ParamFlags.READWRITE |
GObject.ParamFlags.CONSTRUCT,
null),
},
}, class ListSectionItem extends St.BoxLayout {
_init(params) {
this._iconActorBin = new St.Bin();
let textLayout = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
y_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this._title = new St.Label({style_class: 'dialog-list-item-title'});
this._description = new St.Label({
style_class: 'dialog-list-item-title-description',
});
textLayout.add_child(this._title);
textLayout.add_child(this._description);
super._init({
style_class: 'dialog-list-item',
...params,
});
this.label_actor = this._title;
this.add_child(this._iconActorBin);
this.add_child(textLayout);
}
get iconActor() {
return this._iconActorBin.get_child();
}
set iconActor(actor) {
this._iconActorBin.set_child(actor);
this.notify('icon-actor');
}
get title() {
return this._title.text;
}
set title(title) {
_setLabel(this._title, title);
this.notify('title');
}
get description() {
return this._description.text;
}
set description(description) {
_setLabel(this._description, description);
this.notify('description');
}
});

892
js/ui/dnd.js Normal file
View file

@ -0,0 +1,892 @@
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Signals from '../misc/signals.js';
import * as Main from './main.js';
import * as Params from '../misc/params.js';
// Time to scale down to maxDragActorSize
const SCALE_ANIMATION_TIME = 250;
// Time to animate to original position on cancel
const SNAP_BACK_ANIMATION_TIME = 250;
// Time to animate to original position on success
const REVERT_ANIMATION_TIME = 750;
/** @enum {number} */
export const DragMotionResult = {
NO_DROP: 0,
COPY_DROP: 1,
MOVE_DROP: 2,
CONTINUE: 3,
};
/** @enum {number} */
const DragState = {
INIT: 0,
DRAGGING: 1,
CANCELLED: 2,
};
const DRAG_CURSOR_MAP = {
0: Meta.Cursor.NO_DROP,
1: Meta.Cursor.COPY,
2: Meta.Cursor.MOVE,
};
export const DragDropResult = {
FAILURE: 0,
SUCCESS: 1,
CONTINUE: 2,
};
export const dragMonitors = [];
let eventHandlerActor = null;
let currentDraggable = null;
function _getEventHandlerActor() {
if (!eventHandlerActor) {
eventHandlerActor = new Clutter.Actor({width: 0, height: 0, reactive: true});
Main.uiGroup.add_child(eventHandlerActor);
// We connect to 'event' rather than 'captured-event' because the capturing phase doesn't happen
// when you've grabbed the pointer.
eventHandlerActor.connect('event', (actor, event) => {
return currentDraggable._onEvent(actor, event);
});
}
return eventHandlerActor;
}
function _getRealActorScale(actor) {
let scale = 1.0;
while (actor) {
scale *= actor.scale_x;
actor = actor.get_parent();
}
return scale;
}
/**
* @typedef {object} DragMonitor
* @property {Function} dragMotion
*/
/**
* @param {DragMonitor} monitor
*/
export function addDragMonitor(monitor) {
dragMonitors.push(monitor);
}
/**
* @param {DragMonitor} monitor
*/
export function removeDragMonitor(monitor) {
for (let i = 0; i < dragMonitors.length; i++) {
if (dragMonitors[i] === monitor) {
dragMonitors.splice(i, 1);
return;
}
}
}
class _Draggable extends Signals.EventEmitter {
constructor(actor, params) {
super();
params = Params.parse(params, {
manualMode: false,
timeoutThreshold: 0,
restoreOnSuccess: false,
dragActorMaxSize: undefined,
dragActorOpacity: undefined,
});
this.actor = actor;
this._dragState = DragState.INIT;
if (!params.manualMode) {
this.actor.connect('button-press-event',
this._onButtonPress.bind(this));
this.actor.connect('touch-event',
this._onTouchEvent.bind(this));
}
this.actor.connect('destroy', () => {
this._actorDestroyed = true;
if (this._dragState === DragState.DRAGGING && this._dragCancellable)
this._cancelDrag(global.get_current_time());
this.disconnectAll();
});
this._onEventId = null;
this._touchSequence = null;
this._restoreOnSuccess = params.restoreOnSuccess;
this._dragActorMaxSize = params.dragActorMaxSize;
this._dragActorOpacity = params.dragActorOpacity;
this._dragTimeoutThreshold = params.timeoutThreshold;
this._animationInProgress = false; // The drag is over and the item is in the process of animating to its original position (snapping back or reverting).
this._dragCancellable = true;
}
/**
* addClickAction:
*
* @param {Clutter.ClickAction} action - click action to add to draggable actor
*
* Add @action to the draggable's actor, and set it up so that it does not
* impede drag operations.
*/
addClickAction(action) {
action.connect('clicked', () => (this._actionClicked = true));
action.connect('long-press', (a, actor, state) => {
if (state !== Clutter.LongPressState.CANCEL)
return true;
const event = Clutter.get_current_event();
this._dragTouchSequence = event.get_event_sequence();
if (this._longPressLater)
return true;
// A click cancels a long-press before any click handler is
// run - make sure to not start a drag in that case
const laters = global.compositor.get_laters();
this._longPressLater = laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
delete this._longPressLater;
if (this._actionClicked) {
delete this._actionClicked;
return GLib.SOURCE_REMOVE;
}
action.release();
this.startDrag(
...action.get_coords(),
event.get_time(),
this._dragTouchSequence,
event.get_device());
return GLib.SOURCE_REMOVE;
});
return true;
});
this.actor.add_action(action);
}
_onButtonPress(actor, event) {
if (event.get_button() !== 1)
return Clutter.EVENT_PROPAGATE;
this._grabActor(event.get_device());
let [stageX, stageY] = event.get_coords();
this._dragStartX = stageX;
this._dragStartY = stageY;
this._dragStartTime = event.get_time();
this._dragThresholdIgnored = false;
return Clutter.EVENT_PROPAGATE;
}
_onTouchEvent(actor, event) {
// We only handle touch events here on wayland. On X11
// we do get emulated pointer events, which already works
// for single-touch cases. Besides, the X11 passive touch grab
// set up by Mutter will make us see first the touch events
// and later the pointer events, so it will look like two
// unrelated series of events, we want to avoid double handling
// in these cases.
if (!Meta.is_wayland_compositor())
return Clutter.EVENT_PROPAGATE;
if (event.type() !== Clutter.EventType.TOUCH_BEGIN ||
!global.display.is_pointer_emulating_sequence(event.get_event_sequence()))
return Clutter.EVENT_PROPAGATE;
this._grabActor(event.get_device(), event.get_event_sequence());
this._dragStartTime = event.get_time();
this._dragThresholdIgnored = false;
let [stageX, stageY] = event.get_coords();
this._dragStartX = stageX;
this._dragStartY = stageY;
return Clutter.EVENT_PROPAGATE;
}
_grabDevice(actor, pointer, touchSequence) {
this._grab = global.stage.grab(actor);
this._grabbedDevice = pointer;
this._touchSequence = touchSequence;
}
_ungrabDevice() {
if (this._grab) {
this._grab.dismiss();
this._grab = null;
}
this._touchSequence = null;
this._grabbedDevice = null;
}
_grabActor(device, touchSequence) {
this._grabDevice(this.actor, device, touchSequence);
this._onEventId = this.actor.connect('event',
this._onEvent.bind(this));
}
_ungrabActor() {
if (!this._onEventId)
return;
this._ungrabDevice();
this.actor.disconnect(this._onEventId);
this._onEventId = null;
}
_grabEvents(device, touchSequence) {
if (!this._eventsGrab) {
let grab = Main.pushModal(_getEventHandlerActor());
if ((grab.get_seat_state() & Clutter.GrabState.POINTER) !== 0) {
this._grabDevice(_getEventHandlerActor(), device, touchSequence);
this._eventsGrab = grab;
} else {
Main.popModal(grab);
}
}
}
_ungrabEvents() {
if (this._eventsGrab) {
this._ungrabDevice();
Main.popModal(this._eventsGrab);
this._eventsGrab = null;
}
}
_eventIsRelease(event) {
if (event.type() === Clutter.EventType.BUTTON_RELEASE) {
let buttonMask = Clutter.ModifierType.BUTTON1_MASK |
Clutter.ModifierType.BUTTON2_MASK |
Clutter.ModifierType.BUTTON3_MASK;
/* We only obey the last button release from the device,
* other buttons may get pressed/released during the DnD op.
*/
return (event.get_state() & buttonMask) === 0;
} else if (event.type() === Clutter.EventType.TOUCH_END) {
/* For touch, we only obey the pointer emulating sequence */
return global.display.is_pointer_emulating_sequence(event.get_event_sequence());
}
return false;
}
_onEvent(actor, event) {
let device = event.get_device();
if (this._grabbedDevice &&
device !== this._grabbedDevice &&
device.get_device_type() !== Clutter.InputDeviceType.KEYBOARD_DEVICE)
return Clutter.EVENT_PROPAGATE;
// We intercept BUTTON_RELEASE event to know that the button was released in case we
// didn't start the drag, to drop the draggable in case the drag was in progress, and
// to complete the drag and ensure that whatever happens to be under the pointer does
// not get triggered if the drag was cancelled with Esc.
if (this._eventIsRelease(event)) {
if (this._dragState === DragState.DRAGGING) {
return this._dragActorDropped(event);
} else if ((this._dragActor != null || this._dragState === DragState.CANCELLED) &&
!this._animationInProgress) {
// Drag must have been cancelled with Esc.
this._dragComplete();
return Clutter.EVENT_STOP;
} else {
// Drag has never started.
this._ungrabActor();
return Clutter.EVENT_PROPAGATE;
}
// We intercept MOTION event to figure out if the drag has started and to draw
// this._dragActor under the pointer when dragging is in progress
} else if (event.type() === Clutter.EventType.MOTION ||
(event.type() === Clutter.EventType.TOUCH_UPDATE &&
global.display.is_pointer_emulating_sequence(event.get_event_sequence()))) {
if (this._dragActor && this._dragState === DragState.DRAGGING)
return this._updateDragPosition(event);
else if (this._dragActor == null && this._dragState !== DragState.CANCELLED)
return this._maybeStartDrag(event);
// We intercept KEY_PRESS event so that we can process Esc key press to cancel
// dragging and ignore all other key presses.
} else if (event.type() === Clutter.EventType.KEY_PRESS && this._dragState === DragState.DRAGGING) {
let symbol = event.get_key_symbol();
if (symbol === Clutter.KEY_Escape) {
this._cancelDrag(event.get_time());
return Clutter.EVENT_STOP;
}
}
return Clutter.EVENT_PROPAGATE;
}
/**
* Fake a release event.
* Must be called if you want to intercept release events on draggable
* actors for other purposes (for example if you're using
* PopupMenu.ignoreRelease())
*/
fakeRelease() {
this._ungrabActor();
}
/**
* Directly initiate a drag and drop operation from the given actor.
* This function is useful to call if you've specified manualMode
* for the draggable.
*
* @param {number} stageX - X coordinate of event
* @param {number} stageY - Y coordinate of event
* @param {number} time - Event timestamp
* @param {Clutter.EventSequence=} sequence - Event sequence
* @param {Clutter.InputDevice=} device - device that originated the event
*/
startDrag(stageX, stageY, time, sequence, device) {
if (currentDraggable)
return;
if (device === undefined) {
let event = Clutter.get_current_event();
if (event)
device = event.get_device();
if (device === undefined) {
const backend = this.actor.get_context().get_backend();
const seat = backend.get_default_seat();
device = seat.get_pointer();
}
}
currentDraggable = this;
this._dragState = DragState.DRAGGING;
// Special-case St.Button: the pointer grab messes with the internal
// state, so force a reset to a reasonable state here
if (this.actor instanceof St.Button) {
this.actor.fake_release();
this.actor.hover = false;
}
this.emit('drag-begin', time);
if (this._onEventId)
this._ungrabActor();
this._grabEvents(device, sequence);
global.display.set_cursor(Meta.Cursor.NO_DROP);
this._dragX = this._dragStartX = stageX;
this._dragY = this._dragStartY = stageY;
let scaledWidth, scaledHeight;
if (this.actor._delegate && this.actor._delegate.getDragActor) {
this._dragActor = this.actor._delegate.getDragActor();
Main.uiGroup.add_child(this._dragActor);
Main.uiGroup.set_child_above_sibling(this._dragActor, null);
Shell.util_set_hidden_from_pick(this._dragActor, true);
// Drag actor does not always have to be the same as actor. For example drag actor
// can be an image that's part of the actor. So to perform "snap back" correctly we need
// to know what was the drag actor source.
if (this.actor._delegate.getDragActorSource) {
this._dragActorSource = this.actor._delegate.getDragActorSource();
// If the user dragged from the source, then position
// the dragActor over it. Otherwise, center it
// around the pointer
let [sourceX, sourceY] = this._dragActorSource.get_transformed_position();
let x, y;
if (stageX > sourceX && stageX <= sourceX + this._dragActor.width &&
stageY > sourceY && stageY <= sourceY + this._dragActor.height) {
x = sourceX;
y = sourceY;
} else {
x = stageX - this._dragActor.width / 2;
y = stageY - this._dragActor.height / 2;
}
this._dragActor.set_position(x, y);
this._dragActorSourceDestroyId = this._dragActorSource.connect('destroy', () => {
this._dragActorSource = null;
});
} else {
this._dragActorSource = this.actor;
}
this._dragOrigParent = undefined;
this._dragOffsetX = this._dragActor.x - this._dragStartX;
this._dragOffsetY = this._dragActor.y - this._dragStartY;
[scaledWidth, scaledHeight] = this._dragActor.get_transformed_size();
} else {
this._dragActor = this.actor;
this._dragActorSource = undefined;
this._dragOrigParent = this.actor.get_parent();
this._dragActorHadFixedPos = this._dragActor.fixed_position_set;
this._dragOrigX = this._dragActor.allocation.x1;
this._dragOrigY = this._dragActor.allocation.y1;
this._dragActorHadNatWidth = this._dragActor.natural_width_set;
this._dragActorHadNatHeight = this._dragActor.natural_height_set;
this._dragOrigWidth = this._dragActor.allocation.get_width();
this._dragOrigHeight = this._dragActor.allocation.get_height();
this._dragOrigScale = this._dragActor.scale_x;
// Ensure actors with an allocation smaller than their natural size
// retain their size
this._dragActor.set_size(...this._dragActor.allocation.get_size());
const transformedExtents = this._dragActor.get_transformed_extents();
this._dragOffsetX = transformedExtents.origin.x - this._dragStartX;
this._dragOffsetY = transformedExtents.origin.y - this._dragStartY;
scaledWidth = transformedExtents.get_width();
scaledHeight = transformedExtents.get_height();
this._dragActor.scale_x = scaledWidth / this._dragOrigWidth;
this._dragActor.scale_y = scaledHeight / this._dragOrigHeight;
this._dragOrigParent.remove_child(this._dragActor);
Main.uiGroup.add_child(this._dragActor);
Main.uiGroup.set_child_above_sibling(this._dragActor, null);
Shell.util_set_hidden_from_pick(this._dragActor, true);
this._dragOrigParentDestroyId = this._dragOrigParent.connect('destroy', () => {
this._dragOrigParent = null;
});
}
this._dragActorDestroyId = this._dragActor.connect('destroy', () => {
// Cancel ongoing animation (if any)
this._finishAnimation();
this._dragActor = null;
if (this._dragState === DragState.DRAGGING)
this._dragState = DragState.CANCELLED;
});
this._dragOrigOpacity = this._dragActor.opacity;
if (this._dragActorOpacity !== undefined)
this._dragActor.opacity = this._dragActorOpacity;
this._snapBackX = this._dragStartX + this._dragOffsetX;
this._snapBackY = this._dragStartY + this._dragOffsetY;
this._snapBackScale = this._dragActor.scale_x;
let origDragOffsetX = this._dragOffsetX;
let origDragOffsetY = this._dragOffsetY;
let [transX, transY] = this._dragActor.get_translation();
this._dragOffsetX -= transX;
this._dragOffsetY -= transY;
this._dragActor.set_position(
this._dragX + this._dragOffsetX,
this._dragY + this._dragOffsetY);
if (this._dragActorMaxSize !== undefined) {
let currentSize = Math.max(scaledWidth, scaledHeight);
if (currentSize > this._dragActorMaxSize) {
let scale = this._dragActorMaxSize / currentSize;
let origScale = this._dragActor.scale_x;
// The position of the actor changes as we scale
// around the drag position, but we can't just tween
// to the final position because that tween would
// fight with updates as the user continues dragging
// the mouse; instead we do the position computations in
// a ::new-frame handler.
this._dragActor.ease({
scale_x: scale * origScale,
scale_y: scale * origScale,
duration: SCALE_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
this._updateActorPosition(origScale,
origDragOffsetX, origDragOffsetY, transX, transY);
},
});
this._dragActor.get_transition('scale-x').connect('new-frame', () => {
this._updateActorPosition(origScale,
origDragOffsetX, origDragOffsetY, transX, transY);
});
}
}
}
_updateActorPosition(origScale, origDragOffsetX, origDragOffsetY, transX, transY) {
const currentScale = this._dragActor.scale_x / origScale;
this._dragOffsetX = currentScale * origDragOffsetX - transX;
this._dragOffsetY = currentScale * origDragOffsetY - transY;
this._dragActor.set_position(
this._dragX + this._dragOffsetX,
this._dragY + this._dragOffsetY);
}
_maybeStartDrag(event) {
let [stageX, stageY] = event.get_coords();
if (this._dragThresholdIgnored)
return Clutter.EVENT_PROPAGATE;
// See if the user has moved the mouse enough to trigger a drag
let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
let threshold = St.Settings.get().drag_threshold * scaleFactor;
if (!currentDraggable &&
(Math.abs(stageX - this._dragStartX) > threshold ||
Math.abs(stageY - this._dragStartY) > threshold)) {
const deviceType = event.get_source_device().get_device_type();
const isPointerOrTouchpad =
deviceType === Clutter.InputDeviceType.POINTER_DEVICE ||
deviceType === Clutter.InputDeviceType.TOUCHPAD_DEVICE;
const ellapsedTime = event.get_time() - this._dragStartTime;
// Pointer devices (e.g. mouse) start the drag immediately
if (isPointerOrTouchpad || ellapsedTime > this._dragTimeoutThreshold) {
this.startDrag(stageX, stageY, event.get_time(), this._touchSequence, event.get_device());
this._updateDragPosition(event);
} else {
this._dragThresholdIgnored = true;
this._ungrabActor();
return Clutter.EVENT_PROPAGATE;
}
}
return Clutter.EVENT_STOP;
}
_pickTargetActor() {
return this._dragActor.get_stage().get_actor_at_pos(
Clutter.PickMode.ALL, this._dragX, this._dragY);
}
_updateDragHover() {
this._updateHoverId = 0;
let target = this._pickTargetActor();
let dragEvent = {
x: this._dragX,
y: this._dragY,
dragActor: this._dragActor,
source: this.actor._delegate,
targetActor: target,
};
let targetActorDestroyHandlerId;
let handleTargetActorDestroyClosure;
handleTargetActorDestroyClosure = () => {
target = this._pickTargetActor();
dragEvent.targetActor = target;
targetActorDestroyHandlerId =
target.connect('destroy', handleTargetActorDestroyClosure);
};
targetActorDestroyHandlerId =
target.connect('destroy', handleTargetActorDestroyClosure);
for (let i = 0; i < dragMonitors.length; i++) {
let motionFunc = dragMonitors[i].dragMotion;
if (motionFunc) {
let result = motionFunc(dragEvent);
if (result !== DragMotionResult.CONTINUE) {
global.display.set_cursor(DRAG_CURSOR_MAP[result]);
dragEvent.targetActor.disconnect(targetActorDestroyHandlerId);
return GLib.SOURCE_REMOVE;
}
}
}
dragEvent.targetActor.disconnect(targetActorDestroyHandlerId);
while (target) {
if (target._delegate && target._delegate.handleDragOver) {
let [r_, targX, targY] = target.transform_stage_point(this._dragX, this._dragY);
// We currently loop through all parents on drag-over even if one of the children has handled it.
// We can check the return value of the function and break the loop if it's true if we don't want
// to continue checking the parents.
let result = target._delegate.handleDragOver(
this.actor._delegate,
this._dragActor,
targX,
targY,
0);
if (result !== DragMotionResult.CONTINUE) {
global.display.set_cursor(DRAG_CURSOR_MAP[result]);
return GLib.SOURCE_REMOVE;
}
}
target = target.get_parent();
}
global.display.set_cursor(Meta.Cursor.NO_DROP);
return GLib.SOURCE_REMOVE;
}
_queueUpdateDragHover() {
if (this._updateHoverId)
return;
this._updateHoverId = GLib.idle_add(GLib.PRIORITY_DEFAULT,
this._updateDragHover.bind(this));
GLib.Source.set_name_by_id(this._updateHoverId, '[gnome-shell] this._updateDragHover');
}
_updateDragPosition(event) {
let [stageX, stageY] = event.get_coords();
this._dragX = stageX;
this._dragY = stageY;
this._dragActor.set_position(
stageX + this._dragOffsetX,
stageY + this._dragOffsetY);
this._queueUpdateDragHover();
return true;
}
_dragActorDropped(event) {
let [dropX, dropY] = event.get_coords();
let target = this._dragActor.get_stage().get_actor_at_pos(
Clutter.PickMode.ALL, dropX, dropY);
// We call observers only once per motion with the innermost
// target actor. If necessary, the observer can walk the
// parent itself.
let dropEvent = {
dropActor: this._dragActor,
targetActor: target,
clutterEvent: event,
};
for (let i = 0; i < dragMonitors.length; i++) {
let dropFunc = dragMonitors[i].dragDrop;
if (dropFunc) {
switch (dropFunc(dropEvent)) {
case DragDropResult.FAILURE:
case DragDropResult.SUCCESS:
return true;
case DragDropResult.CONTINUE:
continue;
}
}
}
// At this point it is too late to cancel a drag by destroying
// the actor, the fate of which is decided by acceptDrop and its
// side-effects
this._dragCancellable = false;
while (target) {
if (target._delegate && target._delegate.acceptDrop) {
let [r_, targX, targY] = target.transform_stage_point(dropX, dropY);
let accepted = false;
try {
accepted = target._delegate.acceptDrop(this.actor._delegate,
this._dragActor, targX, targY, event.get_time());
} catch (e) {
// On error, skip this target
logError(e, 'Skipping drag target');
}
if (accepted) {
// If it accepted the drop without taking the actor,
// handle it ourselves.
if (this._dragActor && this._dragActor.get_parent() === Main.uiGroup) {
if (this._restoreOnSuccess) {
this._restoreDragActor(event.get_time());
return true;
} else {
this._dragActor.destroy();
}
}
this._dragState = DragState.INIT;
global.display.set_cursor(Meta.Cursor.DEFAULT);
this.emit('drag-end', event.get_time(), true);
this._dragComplete();
return true;
}
}
target = target.get_parent();
}
this._cancelDrag(event.get_time());
return true;
}
_getRestoreLocation() {
let x, y, scale;
if (this._dragActorSource && this._dragActorSource.visible) {
// Snap the clone back to its source
[x, y] = this._dragActorSource.get_transformed_position();
let [sourceScaledWidth] = this._dragActorSource.get_transformed_size();
scale = sourceScaledWidth ? sourceScaledWidth / this._dragActor.width : 0;
} else if (this._dragOrigParent) {
// Snap the actor back to its original position within
// its parent, adjusting for the fact that the parent
// may have been moved or scaled
let [parentX, parentY] = this._dragOrigParent.get_transformed_position();
let parentScale = _getRealActorScale(this._dragOrigParent);
x = parentX + parentScale * this._dragOrigX;
y = parentY + parentScale * this._dragOrigY;
scale = this._dragOrigScale * parentScale;
} else {
// Snap back actor to its original stage position
x = this._snapBackX;
y = this._snapBackY;
scale = this._snapBackScale;
}
return [x, y, scale];
}
_cancelDrag(eventTime) {
this.emit('drag-cancelled', eventTime);
let wasCancelled = this._dragState === DragState.CANCELLED;
this._dragState = DragState.CANCELLED;
if (this._actorDestroyed || wasCancelled) {
global.display.set_cursor(Meta.Cursor.DEFAULT);
this._dragComplete();
this.emit('drag-end', eventTime, false);
if (!this._dragOrigParent && this._dragActor)
this._dragActor.destroy();
return;
}
let [snapBackX, snapBackY, snapBackScale] = this._getRestoreLocation();
this._animateDragEnd(eventTime, {
x: snapBackX,
y: snapBackY,
scale_x: snapBackScale,
scale_y: snapBackScale,
duration: SNAP_BACK_ANIMATION_TIME,
});
}
_restoreDragActor(eventTime) {
this._dragState = DragState.INIT;
let [restoreX, restoreY, restoreScale] = this._getRestoreLocation();
// fade the actor back in at its original location
this._dragActor.set_position(restoreX, restoreY);
this._dragActor.set_scale(restoreScale, restoreScale);
this._dragActor.opacity = 0;
this._animateDragEnd(eventTime, {
duration: REVERT_ANIMATION_TIME,
});
}
_animateDragEnd(eventTime, params) {
this._animationInProgress = true;
// start the animation
this._dragActor.ease({
...params,
opacity: this._dragOrigOpacity,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
this._onAnimationComplete(this._dragActor, eventTime);
},
});
}
_finishAnimation() {
if (!this._animationInProgress)
return;
this._animationInProgress = false;
this._dragComplete();
global.display.set_cursor(Meta.Cursor.DEFAULT);
}
_onAnimationComplete(dragActor, eventTime) {
if (this._dragOrigParent) {
Main.uiGroup.remove_child(this._dragActor);
this._dragOrigParent.add_child(this._dragActor);
dragActor.set_scale(this._dragOrigScale, this._dragOrigScale);
if (this._dragActorHadFixedPos)
dragActor.set_position(this._dragOrigX, this._dragOrigY);
else
dragActor.fixed_position_set = false;
if (this._dragActorHadNatWidth)
this._dragActor.set_width(-1);
if (this._dragActorHadNatHeight)
this._dragActor.set_height(-1);
} else {
dragActor.destroy();
}
this.emit('drag-end', eventTime, false);
this._finishAnimation();
}
_dragComplete() {
if (!this._actorDestroyed && this._dragActor)
Shell.util_set_hidden_from_pick(this._dragActor, false);
this._ungrabEvents();
if (this._updateHoverId) {
GLib.source_remove(this._updateHoverId);
this._updateHoverId = 0;
}
if (this._dragActor) {
this._dragActor.disconnect(this._dragActorDestroyId);
this._dragActor = null;
}
if (this._dragOrigParent) {
this._dragOrigParent.disconnect(this._dragOrigParentDestroyId);
this._dragOrigParent = null;
}
if (this._dragActorSource) {
this._dragActorSource.disconnect(this._dragActorSourceDestroyId);
this._dragActorSource = null;
}
this._dragState = DragState.INIT;
currentDraggable = null;
}
}
/**
* Create an object which controls drag and drop for the given actor.
*
* If %manualMode is %true in @params, do not automatically start
* drag and drop on click
*
* If %dragActorMaxSize is present in @params, the drag actor will
* be scaled down to be no larger than that size in pixels.
*
* If %dragActorOpacity is present in @params, the drag actor will
* will be set to have that opacity during the drag.
*
* Note that when the drag actor is the source actor and the drop
* succeeds, the actor scale and opacity aren't reset; if the drop
* target wants to reuse the actor, it's up to the drop target to
* reset these values.
*
* @param {Clutter.Actor} actor Source actor
* @param {object} [params] Additional parameters
* @returns {_Draggable} a new Draggable
*/
export function makeDraggable(actor, params) {
return new _Draggable(actor, params);
}

87
js/ui/edgeDragAction.js Normal file
View file

@ -0,0 +1,87 @@
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Mtk from 'gi://Mtk';
import St from 'gi://St';
import * as Main from './main.js';
const EDGE_THRESHOLD = 20;
const DRAG_DISTANCE = 80;
export const EdgeDragAction = GObject.registerClass({
Signals: {
'activated': {},
'progress': {param_types: [GObject.TYPE_DOUBLE]},
},
}, class EdgeDragAction extends Clutter.GestureAction {
_init(side, allowedModes) {
super._init();
this._side = side;
this._allowedModes = allowedModes;
this.set_n_touch_points(1);
this.set_threshold_trigger_edge(Clutter.GestureTriggerEdge.AFTER);
}
_getMonitorRect(x, y) {
const rect = new Mtk.Rectangle({x: x - 1, y: y - 1, width: 1, height: 1});
let monitorIndex = global.display.get_monitor_index_for_rect(rect);
return global.display.get_monitor_geometry(monitorIndex);
}
vfunc_gesture_prepare(_actor) {
if (this.get_n_current_points() === 0)
return false;
if (!(this._allowedModes & Main.actionMode))
return false;
let [x, y] = this.get_press_coords(0);
let monitorRect = this._getMonitorRect(x, y);
return (this._side === St.Side.LEFT && x < monitorRect.x + EDGE_THRESHOLD) ||
(this._side === St.Side.RIGHT && x > monitorRect.x + monitorRect.width - EDGE_THRESHOLD) ||
(this._side === St.Side.TOP && y < monitorRect.y + EDGE_THRESHOLD) ||
(this._side === St.Side.BOTTOM && y > monitorRect.y + monitorRect.height - EDGE_THRESHOLD);
}
vfunc_gesture_progress(_actor) {
let [startX, startY] = this.get_press_coords(0);
let [x, y] = this.get_motion_coords(0);
let offsetX = Math.abs(x - startX);
let offsetY = Math.abs(y - startY);
if (offsetX < EDGE_THRESHOLD && offsetY < EDGE_THRESHOLD)
return true;
if ((offsetX > offsetY &&
(this._side === St.Side.TOP || this._side === St.Side.BOTTOM)) ||
(offsetY > offsetX &&
(this._side === St.Side.LEFT || this._side === St.Side.RIGHT))) {
this.cancel();
return false;
}
if (this._side === St.Side.TOP ||
this._side === St.Side.BOTTOM)
this.emit('progress', offsetY);
else
this.emit('progress', offsetX);
return true;
}
vfunc_gesture_end(_actor) {
let [startX, startY] = this.get_press_coords(0);
let [x, y] = this.get_motion_coords(0);
let monitorRect = this._getMonitorRect(startX, startY);
if ((this._side === St.Side.TOP && y > monitorRect.y + DRAG_DISTANCE) ||
(this._side === St.Side.BOTTOM && y < monitorRect.y + monitorRect.height - DRAG_DISTANCE) ||
(this._side === St.Side.LEFT && x > monitorRect.x + DRAG_DISTANCE) ||
(this._side === St.Side.RIGHT && x < monitorRect.x + monitorRect.width - DRAG_DISTANCE))
this.emit('activated');
else
this.cancel();
}
});

797
js/ui/endSessionDialog.js Normal file
View file

@ -0,0 +1,797 @@
/*
* Copyright 2010-2016 Red Hat, Inc
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, see <http://www.gnu.org/licenses/>.
*/
import AccountsService from 'gi://AccountsService';
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import Polkit from 'gi://Polkit';
import Shell from 'gi://Shell';
import St from 'gi://St';
import UPower from 'gi://UPowerGlib';
import * as CheckBox from './checkBox.js';
import * as Dialog from './dialog.js';
import * as GnomeSession from '../misc/gnomeSession.js';
import * as LoginManager from '../misc/loginManager.js';
import * as ModalDialog from './modalDialog.js';
import * as UserWidget from './userWidget.js';
import {ModalDialogErrors, ModalDialogError} from '../misc/dbusErrors.js';
import {loadInterfaceXML} from '../misc/fileUtils.js';
const _ITEM_ICON_SIZE = 64;
const LOW_BATTERY_THRESHOLD = 30;
const EndSessionDialogIface = loadInterfaceXML('org.gnome.SessionManager.EndSessionDialog');
const logoutDialogContent = {
subjectWithUser: C_('title', 'Log Out %s'),
subject: C_('title', 'Log Out'),
descriptionWithUser(user, seconds) {
return ngettext(
'%s will be logged out automatically in %d second',
'%s will be logged out automatically in %d seconds',
seconds).format(user, seconds);
},
description(seconds) {
return ngettext(
'You will be logged out automatically in %d second',
'You will be logged out automatically in %d seconds',
seconds).format(seconds);
},
showBatteryWarning: false,
confirmButtons: [{
signal: 'ConfirmedLogout',
label: C_('button', 'Log Out'),
}],
showOtherSessions: false,
};
const shutdownDialogContent = {
subject: C_('title', 'Power Off'),
subjectWithUpdates: C_('title', 'Install Updates & Power Off'),
description(seconds) {
return ngettext(
'The system will power off automatically in %d second',
'The system will power off automatically in %d seconds',
seconds).format(seconds);
},
checkBoxText: C_('checkbox', 'Install pending software updates'),
showBatteryWarning: true,
confirmButtons: [{
signal: 'ConfirmedShutdown',
label: C_('button', 'Power Off'),
}],
iconName: 'system-shutdown-symbolic',
showOtherSessions: true,
};
const restartDialogContent = {
subject: C_('title', 'Restart'),
subjectWithUpdates: C_('title', 'Install Updates & Restart'),
description(seconds) {
return ngettext(
'The system will restart automatically in %d second',
'The system will restart automatically in %d seconds',
seconds).format(seconds);
},
checkBoxText: C_('checkbox', 'Install pending software updates'),
showBatteryWarning: true,
confirmButtons: [{
signal: 'ConfirmedReboot',
label: C_('button', 'Restart'),
}],
iconName: 'view-refresh-symbolic',
showOtherSessions: true,
};
const restartUpdateDialogContent = {
subject: C_('title', 'Restart & Install Updates'),
description(seconds) {
return ngettext(
'The system will automatically restart and install updates in %d second',
'The system will automatically restart and install updates in %d seconds',
seconds).format(seconds);
},
showBatteryWarning: true,
confirmButtons: [{
signal: 'ConfirmedReboot',
label: C_('button', 'Restart & Install'),
}],
unusedFutureButtonForTranslation: C_('button', 'Install & Power Off'),
unusedFutureCheckBoxForTranslation: C_('checkbox', 'Power off after updates are installed'),
iconName: 'view-refresh-symbolic',
showOtherSessions: true,
};
const restartUpgradeDialogContent = {
subject: C_('title', 'Restart & Install Upgrade'),
upgradeDescription(distroName, distroVersion) {
/* Translators: This is the text displayed for system upgrades in the
shut down dialog. First %s gets replaced with the distro name and
second %s with the distro version to upgrade to */
return _('%s %s will be installed after restart. Upgrade installation can take a long time: ensure that you have backed up and that the computer is plugged in.').format(distroName, distroVersion);
},
disableTimer: true,
showBatteryWarning: false,
confirmButtons: [{
signal: 'ConfirmedReboot',
label: C_('button', 'Restart & Install'),
}],
iconName: 'view-refresh-symbolic',
showOtherSessions: true,
};
const DialogType = {
LOGOUT: 0 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_LOGOUT */,
SHUTDOWN: 1 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_SHUTDOWN */,
RESTART: 2 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_RESTART */,
UPDATE_RESTART: 3,
UPGRADE_RESTART: 4,
};
const DialogContent = {
0 /* DialogType.LOGOUT */: logoutDialogContent,
1 /* DialogType.SHUTDOWN */: shutdownDialogContent,
2 /* DialogType.RESTART */: restartDialogContent,
3 /* DialogType.UPDATE_RESTART */: restartUpdateDialogContent,
4 /* DialogType.UPGRADE_RESTART */: restartUpgradeDialogContent,
};
const MAX_USERS_IN_SESSION_DIALOG = 5;
const LogindSessionIface = loadInterfaceXML('org.freedesktop.login1.Session');
const LogindSession = Gio.DBusProxy.makeProxyWrapper(LogindSessionIface);
const PkOfflineIface = loadInterfaceXML('org.freedesktop.PackageKit.Offline');
const PkOfflineProxy = Gio.DBusProxy.makeProxyWrapper(PkOfflineIface);
const UPowerIface = loadInterfaceXML('org.freedesktop.UPower.Device');
const UPowerProxy = Gio.DBusProxy.makeProxyWrapper(UPowerIface);
function findAppFromInhibitor(inhibitor) {
let desktopFile;
try {
[desktopFile] = inhibitor.GetAppIdSync();
} catch {
// XXX -- sometimes JIT inhibitors generated by gnome-session
// get removed too soon. Don't fail in this case.
log(`gnome-session gave us a dead inhibitor: ${inhibitor.get_object_path()}`);
return null;
}
if (!GLib.str_has_suffix(desktopFile, '.desktop'))
desktopFile += '.desktop';
return Shell.AppSystem.get_default().lookup_heuristic_basename(desktopFile);
}
// The logout timer only shows updates every 10 seconds
// until the last 10 seconds, then it shows updates every
// second. This function takes a given time and returns
// what we should show to the user for that time.
function _roundSecondsToInterval(totalSeconds, secondsLeft, interval) {
let time;
time = Math.ceil(secondsLeft);
// Final count down is in decrements of 1
if (time <= interval)
return time;
// Round up higher than last displayable time interval
time += interval - 1;
// Then round down to that time interval
if (time > totalSeconds)
time = Math.ceil(totalSeconds);
else
time -= time % interval;
return time;
}
function _setCheckBoxLabel(checkBox, text) {
let label = checkBox.getLabelActor();
if (text) {
label.set_text(text);
checkBox.show();
} else {
label.set_text('');
checkBox.hide();
}
}
export const EndSessionDialog = GObject.registerClass(
class EndSessionDialog extends ModalDialog.ModalDialog {
_init() {
super._init({
styleClass: 'end-session-dialog',
destroyOnClose: false,
});
this._loginManager = LoginManager.getLoginManager();
this._canRebootToBootLoaderMenu = false;
this._getCanRebootToBootLoaderMenu().catch(logError);
this._userManager = AccountsService.UserManager.get_default();
this._user = this._userManager.get_user(GLib.get_user_name());
this._updatesPermission = null;
this._pkOfflineProxy = new PkOfflineProxy(Gio.DBus.system,
'org.freedesktop.PackageKit',
'/org/freedesktop/PackageKit',
this._onPkOfflineProxyCreated.bind(this));
this._powerProxy = new UPowerProxy(Gio.DBus.system,
'org.freedesktop.UPower',
'/org/freedesktop/UPower/devices/DisplayDevice',
(proxy, error) => {
if (error) {
log(error.message);
return;
}
this._powerProxy.connect('g-properties-changed',
this._sync.bind(this));
this._sync();
});
this._secondsLeft = 0;
this._totalSecondsToStayOpen = 0;
this._applications = [];
this._sessions = [];
this._capturedEventId = 0;
this._rebootButton = null;
this._rebootButtonAlt = null;
this.connect('opened', this._onOpened.bind(this));
this._user.connectObject(
'notify::is-loaded', this._sync.bind(this),
'changed', this._sync.bind(this), this);
this._messageDialogContent = new Dialog.MessageDialogContent();
this._checkBox = new CheckBox.CheckBox();
this._checkBox.connect('clicked', this._sync.bind(this));
this._messageDialogContent.add_child(this._checkBox);
this._batteryWarning = new St.Label({
style_class: 'end-session-dialog-battery-warning',
text: _('Low battery power: please plug in before installing updates'),
});
this._batteryWarning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._batteryWarning.clutter_text.line_wrap = true;
this._messageDialogContent.add_child(this._batteryWarning);
this.contentLayout.add_child(this._messageDialogContent);
this._applicationSection = new Dialog.ListSection({
title: _('Some applications are busy or have unsaved work'),
});
this.contentLayout.add_child(this._applicationSection);
this._sessionSection = new Dialog.ListSection({
title: _('Other users are logged in'),
});
this.contentLayout.add_child(this._sessionSection);
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(EndSessionDialogIface, this);
this._dbusImpl.export(Gio.DBus.session, '/org/gnome/SessionManager/EndSessionDialog');
}
async _getCanRebootToBootLoaderMenu() {
const {canRebootToBootLoaderMenu} = await this._loginManager.canRebootToBootLoaderMenu();
this._canRebootToBootLoaderMenu = canRebootToBootLoaderMenu;
}
async _onPkOfflineProxyCreated(proxy, error) {
if (error) {
log(error.message);
return;
}
// Creating a D-Bus proxy won't propagate SERVICE_UNKNOWN or NAME_HAS_NO_OWNER
// errors if PackageKit is not available, but the GIO implementation will make
// sure in that case that the proxy's g-name-owner is set to null, so check that.
if (this._pkOfflineProxy.g_name_owner === null) {
this._pkOfflineProxy = null;
return;
}
// It only makes sense to check for this permission if PackageKit is available.
try {
this._updatesPermission = await Polkit.Permission.new(
'org.freedesktop.packagekit.trigger-offline-update', null, null);
} catch (e) {
log(`No permission to trigger offline updates: ${e}`);
}
}
_isDischargingBattery() {
return this._powerProxy.IsPresent &&
this._powerProxy.State !== UPower.DeviceState.CHARGING &&
this._powerProxy.State !== UPower.DeviceState.FULLY_CHARGED;
}
_isBatteryLow() {
return this._isDischargingBattery() && this._powerProxy.Percentage < LOW_BATTERY_THRESHOLD;
}
_shouldShowLowBatteryWarning(dialogContent) {
if (!dialogContent.showBatteryWarning)
return false;
if (!this._isBatteryLow())
return false;
if (this._checkBox.checked)
return true;
// Show the warning if updates have already been triggered, but
// the user doesn't have enough permissions to cancel them.
let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed;
return this._updateInfo.UpdatePrepared && this._updateInfo.UpdateTriggered && !updatesAllowed;
}
_sync() {
let open = this.state === ModalDialog.State.OPENING || this.state === ModalDialog.State.OPENED;
if (!open)
return;
let dialogContent = DialogContent[this._type];
let subject = dialogContent.subject;
// Use different title when we are installing updates
if (dialogContent.subjectWithUpdates && this._checkBox.checked)
subject = dialogContent.subjectWithUpdates;
this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent);
let description;
let displayTime = _roundSecondsToInterval(
this._totalSecondsToStayOpen, this._secondsLeft, 10);
if (this._user.is_loaded) {
let realName = this._user.get_real_name();
if (realName != null) {
if (dialogContent.subjectWithUser)
subject = dialogContent.subjectWithUser.format(realName);
if (dialogContent.descriptionWithUser)
description = dialogContent.descriptionWithUser(realName, displayTime);
}
}
// Use a different description when we are installing a system upgrade
// if the PackageKit proxy is available (i.e. PackageKit is available).
if (dialogContent.upgradeDescription) {
const {name, version} = this._updateInfo.PreparedUpgrade;
if (name != null && version != null)
description = dialogContent.upgradeDescription(name, version);
}
// Fall back to regular description
if (!description)
description = dialogContent.description(displayTime);
this._messageDialogContent.title = subject;
this._messageDialogContent.description = description;
let hasApplications = this._applications.length > 0;
let hasSessions = this._sessions.length > 0;
this._applicationSection.visible = hasApplications;
this._sessionSection.visible = hasSessions;
}
_onCapturedEvent(actor, event) {
let altEnabled = false;
let type = event.type();
if (type !== Clutter.EventType.KEY_PRESS && type !== Clutter.EventType.KEY_RELEASE)
return Clutter.EVENT_PROPAGATE;
let key = event.get_key_symbol();
if (key !== Clutter.KEY_Alt_L && key !== Clutter.KEY_Alt_R)
return Clutter.EVENT_PROPAGATE;
if (type === Clutter.EventType.KEY_PRESS)
altEnabled = true;
this._rebootButton.visible = !altEnabled;
this._rebootButtonAlt.visible = altEnabled;
return Clutter.EVENT_PROPAGATE;
}
_updateButtons() {
this.clearButtons();
this.addButton({
action: this.cancel.bind(this),
label: _('Cancel'),
key: Clutter.KEY_Escape,
});
let dialogContent = DialogContent[this._type];
for (let i = 0; i < dialogContent.confirmButtons.length; i++) {
let signal = dialogContent.confirmButtons[i].signal;
let label = dialogContent.confirmButtons[i].label;
let button = this.addButton({
action: () => {
let signalId = this.connect('closed', () => {
this.disconnect(signalId);
this._confirm(signal).catch(logError);
});
this.close(true);
},
label,
});
// Add Alt "Boot Options" option to the Reboot button
if (this._canRebootToBootLoaderMenu && signal === 'ConfirmedReboot') {
this._rebootButton = button;
this._rebootButtonAlt = this.addButton({
action: () => {
this.close(true);
let signalId = this.connect('closed', () => {
this.disconnect(signalId);
this._confirmRebootToBootLoaderMenu();
});
},
label: C_('button', 'Boot Options'),
});
this._rebootButtonAlt.visible = false;
this._capturedEventId = this.connect('captured-event',
this._onCapturedEvent.bind(this));
}
}
}
_stopAltCapture() {
if (this._capturedEventId > 0) {
this.disconnect(this._capturedEventId);
this._capturedEventId = 0;
}
this._rebootButton = null;
this._rebootButtonAlt = null;
}
close(skipSignal) {
super.close();
if (!skipSignal)
this._dbusImpl.emit_signal('Closed', null);
}
cancel() {
this._stopTimer();
this._stopAltCapture();
this._dbusImpl.emit_signal('Canceled', null);
this.close();
}
_confirmRebootToBootLoaderMenu() {
this._loginManager.setRebootToBootLoaderMenu();
this._confirm('ConfirmedReboot').catch(logError);
}
async _confirm(signal) {
if (this._checkBox.visible) {
// Trigger the offline update as requested
if (this._checkBox.checked) {
switch (signal) {
case 'ConfirmedReboot':
await this._triggerOfflineUpdateReboot();
break;
case 'ConfirmedShutdown':
// To actually trigger the offline update, we need to
// reboot to do the upgrade. When the upgrade is complete,
// the computer will shut down automatically.
signal = 'ConfirmedReboot';
await this._triggerOfflineUpdateShutdown();
break;
default:
break;
}
} else {
await this._triggerOfflineUpdateCancel();
}
}
this._fadeOutDialog();
this._stopTimer();
this._stopAltCapture();
this._dbusImpl.emit_signal(signal, null);
}
_onOpened() {
this._sync();
}
async _triggerOfflineUpdateReboot() {
// Handle this gracefully if PackageKit is not available.
if (!this._pkOfflineProxy)
return;
try {
await this._pkOfflineProxy.TriggerAsync('reboot');
} catch (error) {
log(error.message);
}
}
async _triggerOfflineUpdateShutdown() {
// Handle this gracefully if PackageKit is not available.
if (!this._pkOfflineProxy)
return;
try {
await this._pkOfflineProxy.TriggerAsync('power-off');
} catch (error) {
log(error.message);
}
}
async _triggerOfflineUpdateCancel() {
// Handle this gracefully if PackageKit is not available.
if (!this._pkOfflineProxy)
return;
try {
await this._pkOfflineProxy.CancelAsync();
} catch (error) {
log(error.message);
}
}
_startTimer() {
let startTime = GLib.get_monotonic_time();
this._secondsLeft = this._totalSecondsToStayOpen;
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
let currentTime = GLib.get_monotonic_time();
let secondsElapsed = (currentTime - startTime) / 1000000;
this._secondsLeft = this._totalSecondsToStayOpen - secondsElapsed;
if (this._secondsLeft > 0) {
this._sync();
return GLib.SOURCE_CONTINUE;
}
let dialogContent = DialogContent[this._type];
let button = dialogContent.confirmButtons[dialogContent.confirmButtons.length - 1];
this._confirm(button.signal).catch(logError);
this._timerId = 0;
return GLib.SOURCE_REMOVE;
});
GLib.Source.set_name_by_id(this._timerId, '[gnome-shell] this._confirm');
}
_stopTimer() {
if (this._timerId > 0) {
GLib.source_remove(this._timerId);
this._timerId = 0;
}
this._secondsLeft = 0;
}
_onInhibitorLoaded(inhibitor) {
if (!this._applications.includes(inhibitor)) {
// Stale inhibitor
return;
}
let app = findAppFromInhibitor(inhibitor);
const [flags] = app ? inhibitor.GetFlagsSync() : [0];
if (app && flags & GnomeSession.InhibitFlags.LOGOUT) {
let [description] = inhibitor.GetReasonSync();
let listItem = new Dialog.ListSectionItem({
icon_actor: app.create_icon_texture(_ITEM_ICON_SIZE),
title: app.get_name(),
description,
});
this._applicationSection.list.add_child(listItem);
} else {
// inhibiting app is a service (not an application) or is not
// inhibiting logout/shutdown
this._applications.splice(this._applications.indexOf(inhibitor), 1);
}
this._sync();
}
async _loadSessions() {
let sessionId = GLib.getenv('XDG_SESSION_ID');
if (!sessionId) {
const currentSessionProxy = await this._loginManager.getCurrentSessionProxy();
sessionId = currentSessionProxy.Id;
log(`endSessionDialog: No XDG_SESSION_ID, fetched from logind: ${sessionId}`);
}
const sessions = await this._loginManager.listSessions();
for (const [id_, uid_, userName, seat_, sessionPath] of sessions) {
let proxy = new LogindSession(Gio.DBus.system, 'org.freedesktop.login1', sessionPath);
if (proxy.Class !== 'user')
continue;
if (proxy.State === 'closing')
continue;
if (proxy.Id === sessionId)
continue;
const session = {
user: this._userManager.get_user(userName),
username: userName,
type: proxy.Type,
remote: proxy.Remote,
};
const nSessions = this._sessions.push(session);
let userAvatar = new UserWidget.Avatar(session.user, {
iconSize: _ITEM_ICON_SIZE,
});
userAvatar.update();
const displayUserName =
session.user.get_real_name() ?? session.username;
let userLabelText;
if (session.remote)
/* Translators: Remote here refers to a remote session, like a ssh login */
userLabelText = _('%s (remote)').format(displayUserName);
else if (session.type === 'tty')
/* Translators: Console here refers to a tty like a VT console */
userLabelText = _('%s (console)').format(displayUserName);
else
userLabelText = userName;
let listItem = new Dialog.ListSectionItem({
icon_actor: userAvatar,
title: userLabelText,
});
this._sessionSection.list.add_child(listItem);
// limit the number of entries
if (nSessions === MAX_USERS_IN_SESSION_DIALOG)
break;
}
this._sync();
}
async _getUpdateInfo() {
const connection = this._pkOfflineProxy.get_connection();
const reply = await connection.call(
this._pkOfflineProxy.g_name,
this._pkOfflineProxy.g_object_path,
'org.freedesktop.DBus.Properties',
'GetAll',
new GLib.Variant('(s)', [this._pkOfflineProxy.g_interface_name]),
null,
Gio.DBusCallFlags.NONE,
-1,
null);
const [info] = reply.recursiveUnpack();
return info;
}
async OpenAsync(parameters, invocation) {
let [type, timestamp_, totalSecondsToStayOpen, inhibitorObjectPaths] = parameters;
this._totalSecondsToStayOpen = totalSecondsToStayOpen;
this._type = type;
try {
this._updateInfo = await this._getUpdateInfo();
} catch (e) {
if (this._pkOfflineProxy !== null)
log(`Failed to get update info from PackageKit: ${e.message}`);
this._updateInfo = {
UpdateTriggered: false,
UpdatePrepared: false,
UpgradeTriggered: false,
PreparedUpgrade: {},
};
}
// Only consider updates and upgrades if PackageKit is available.
if (this._pkOfflineProxy && this._type === DialogType.RESTART) {
if (this._updateInfo.UpdateTriggered)
this._type = DialogType.UPDATE_RESTART;
else if (this._updateInfo.UpgradeTriggered)
this._type = DialogType.UPGRADE_RESTART;
}
this._applications = [];
this._applicationSection.list.destroy_all_children();
this._sessions = [];
this._sessionSection.list.destroy_all_children();
if (!(this._type in DialogContent)) {
invocation.return_error_literal(ModalDialogErrors,
ModalDialogError.UNKNOWN_TYPE,
'Unknown dialog type requested');
return;
}
let dialogContent = DialogContent[this._type];
for (let i = 0; i < inhibitorObjectPaths.length; i++) {
let inhibitor = new GnomeSession.Inhibitor(inhibitorObjectPaths[i], proxy => {
this._onInhibitorLoaded(proxy);
});
this._applications.push(inhibitor);
}
if (dialogContent.showOtherSessions)
this._loadSessions().catch(logError);
let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed;
_setCheckBoxLabel(this._checkBox, dialogContent.checkBoxText || '');
this._checkBox.visible = dialogContent.checkBoxText && this._updateInfo.UpdatePrepared && updatesAllowed;
if (this._type === DialogType.UPGRADE_RESTART)
this._checkBox.checked = this._checkBox.visible && this._updateInfo.UpdateTriggered && !this._isDischargingBattery();
else
this._checkBox.checked = this._checkBox.visible && !this._isBatteryLow();
this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent);
this._updateButtons();
if (!this.open()) {
invocation.return_error_literal(
ModalDialogError.GRAB_FAILED,
'Cannot grab pointer and keyboard');
return;
}
if (!dialogContent.disableTimer)
this._startTimer();
this._sync();
let signalId = this.connect('opened', () => {
invocation.return_value(null);
this.disconnect(signalId);
});
}
Close(_parameters, _invocation) {
this.close();
}
});

394
js/ui/environment.js Normal file
View file

@ -0,0 +1,394 @@
// Load all required dependencies with the correct versions
import '../misc/dependencies.js';
import {setConsoleLogDomain} from 'console';
import * as Gettext from 'gettext';
import Cairo from 'cairo';
import Clutter from 'gi://Clutter';
import Gdk from 'gi://Gdk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Mtk from 'gi://Mtk';
import Polkit from 'gi://Polkit';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as SignalTracker from '../misc/signalTracker.js';
import {adjustAnimationTime} from '../misc/animationUtils.js';
setConsoleLogDomain('GNOME Shell');
Gio._promisify(Gio.DataInputStream.prototype, 'fill_async');
Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async');
Gio._promisify(Gio.DBus, 'get');
Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Gio.DBusProxy, 'new');
Gio._promisify(Gio.DBusProxy.prototype, 'init_async');
Gio._promisify(Gio.DBusProxy.prototype, 'call_with_unix_fd_list');
Gio._promisify(Gio.File.prototype, 'query_info_async');
Gio._promisify(Polkit.Permission, 'new');
Gio._promisify(Shell.App.prototype, 'activate_action');
// We can't import shell JS modules yet, because they may have
// variable initializations, etc, that depend on this file's
// changes
function _patchLayoutClass(layoutClass, styleProps) {
if (styleProps) {
layoutClass.prototype.hookup_style = function (container) {
container.connect('style-changed', () => {
let node = container.get_theme_node();
for (let prop in styleProps) {
let [found, length] = node.lookup_length(styleProps[prop], false);
if (found)
this[prop] = length;
}
});
};
}
}
function _makeEaseCallback(params, cleanup) {
let onComplete = params.onComplete;
delete params.onComplete;
let onStopped = params.onStopped;
delete params.onStopped;
return isFinished => {
cleanup();
if (onStopped)
onStopped(isFinished);
if (onComplete && isFinished)
onComplete();
};
}
function _getPropertyTarget(actor, propName) {
if (!propName.startsWith('@'))
return [actor, propName];
let [type, name, prop] = propName.split('.');
switch (type) {
case '@layout':
return [actor.layout_manager, name];
case '@actions':
return [actor.get_action(name), prop];
case '@constraints':
return [actor.get_constraint(name), prop];
case '@content':
return [actor.content, name];
case '@effects':
return [actor.get_effect(name), prop];
}
throw new Error(`Invalid property name ${propName}`);
}
function _easeActor(actor, params) {
params = {
repeatCount: 0,
autoReverse: false,
animationRequired: false,
...params,
};
actor.save_easing_state();
const animationRequired = params.animationRequired;
delete params.animationRequired;
if (params.duration !== undefined)
actor.set_easing_duration(params.duration, {animationRequired});
delete params.duration;
if (params.delay !== undefined)
actor.set_easing_delay(params.delay, {animationRequired});
delete params.delay;
const repeatCount = params.repeatCount;
delete params.repeatCount;
const autoReverse = params.autoReverse;
delete params.autoReverse;
// repeatCount doesn't include the initial iteration
const numIterations = repeatCount + 1;
// whether the transition should finish where it started
const isReversed = autoReverse && numIterations % 2 === 0;
if (params.mode !== undefined)
actor.set_easing_mode(params.mode);
delete params.mode;
const prepare = () => {
global.compositor.disable_unredirect();
global.begin_work();
};
const cleanup = () => {
global.compositor.enable_unredirect();
global.end_work();
};
let callback = _makeEaseCallback(params, cleanup);
// cancel overwritten transitions
let animatedProps = Object.keys(params).map(p => p.replace('_', '-', 'g'));
animatedProps.forEach(p => actor.remove_transition(p));
if (actor.get_easing_duration() > 0 || !isReversed)
actor.set(params);
actor.restore_easing_state();
const transitions = animatedProps
.map(p => actor.get_transition(p))
.filter(t => t !== null);
transitions.forEach(t => t.set({repeatCount, autoReverse}));
const [transition] = transitions;
if (transition && transition.delay)
transition.connect('started', () => prepare());
else
prepare();
if (transition)
transition.connect('stopped', (t, finished) => callback(finished));
else
callback(true);
}
function _easeActorProperty(actor, propName, target, params) {
params = {
repeatCount: 0,
autoReverse: false,
animationRequired: false,
...params,
};
// Avoid pointless difference with ease()
if (params.mode)
params.progress_mode = params.mode;
delete params.mode;
const animationRequired = params.animationRequired;
delete params.animationRequired;
if (params.duration)
params.duration = adjustAnimationTime(params.duration, {animationRequired});
let duration = Math.floor(params.duration || 0);
if (params.delay)
params.delay = adjustAnimationTime(params.delay, {animationRequired});
const repeatCount = params.repeatCount;
delete params.repeatCount;
const autoReverse = params.autoReverse;
delete params.autoReverse;
// repeatCount doesn't include the initial iteration
const numIterations = repeatCount + 1;
// whether the transition should finish where it started
const isReversed = autoReverse && numIterations % 2 === 0;
// Copy Clutter's behavior for implicit animations, see
// should_skip_implicit_transition()
if (actor instanceof Clutter.Actor && !actor.mapped)
duration = 0;
const prepare = () => {
global.compositor.disable_unredirect();
global.begin_work();
};
const cleanup = () => {
global.compositor.enable_unredirect();
global.end_work();
};
let callback = _makeEaseCallback(params, cleanup);
// cancel overwritten transition
actor.remove_transition(propName);
if (duration === 0) {
let [obj, prop] = _getPropertyTarget(actor, propName);
if (!isReversed)
obj[prop] = target;
prepare();
callback(true);
return;
}
let pspec = actor.find_property(propName);
let transition = new Clutter.PropertyTransition({
property_name: propName,
interval: new Clutter.Interval({value_type: pspec.value_type}),
remove_on_complete: true,
repeat_count: repeatCount,
auto_reverse: autoReverse,
...params,
});
actor.add_transition(propName, transition);
transition.set_to(target);
if (transition.delay)
transition.connect('started', () => prepare());
else
prepare();
transition.connect('stopped', (t, finished) => callback(finished));
}
// Add some bindings to the global JS namespace
globalThis.global = Shell.Global.get();
globalThis._ = Gettext.gettext;
globalThis.C_ = Gettext.pgettext;
globalThis.ngettext = Gettext.ngettext;
globalThis.N_ = s => s;
GObject.gtypeNameBasedOnJSPath = true;
GObject.Object.prototype.connectObject = function (...args) {
SignalTracker.connectObject(this, ...args);
};
GObject.Object.prototype.connect_object = function (...args) {
SignalTracker.connectObject(this, ...args);
};
GObject.Object.prototype.disconnectObject = function (...args) {
SignalTracker.disconnectObject(this, ...args);
};
GObject.Object.prototype.disconnect_object = function (...args) {
SignalTracker.disconnectObject(this, ...args);
};
SignalTracker.registerDestroyableType(Clutter.Actor);
Cairo.Context.prototype.setSourceColor = function (color) {
const {red, green, blue, alpha} = color;
const rgb = [red, green, blue].map(v => v / 255.0);
if (alpha !== 0xff)
this.setSourceRGBA(...rgb, alpha / 255.0);
else
this.setSourceRGB(...rgb);
};
// Miscellaneous monkeypatching
_patchLayoutClass(Clutter.GridLayout, {
row_spacing: 'spacing-rows',
column_spacing: 'spacing-columns',
});
_patchLayoutClass(Clutter.BoxLayout, {spacing: 'spacing'});
const origSetEasingDuration = Clutter.Actor.prototype.set_easing_duration;
Clutter.Actor.prototype.set_easing_duration = function (msecs, params = {}) {
origSetEasingDuration.call(this, adjustAnimationTime(msecs, params));
};
const origSetEasingDelay = Clutter.Actor.prototype.set_easing_delay;
Clutter.Actor.prototype.set_easing_delay = function (msecs, params = {}) {
origSetEasingDelay.call(this, adjustAnimationTime(msecs, params));
};
Clutter.Actor.prototype.ease = function (props) {
_easeActor(this, props);
};
Clutter.Actor.prototype.ease_property = function (propName, target, params) {
_easeActorProperty(this, propName, target, params);
};
St.Adjustment.prototype.ease = function (target, params) {
// we're not an actor of course, but we implement the same
// transition API as Clutter.Actor, so this works anyway
_easeActorProperty(this, 'value', target, params);
};
Clutter.Actor.prototype[Symbol.iterator] = function* () {
for (let c = this.get_first_child(); c; c = c.get_next_sibling())
yield c;
};
Clutter.Actor.prototype.toString = function () {
return St.describe_actor(this);
};
// Deprecation warning for former JS classes turned into an actor subclass
Object.defineProperty(Clutter.Actor.prototype, 'actor', {
get() {
let klass = this.constructor.name;
let {stack} = new Error();
log(`Usage of object.actor is deprecated for ${klass}\n${stack}`);
return this;
},
});
Meta.Rectangle = function (params = {}) {
console.warn('Meta.Rectangle is deprecated, use Mtk.Rectangle instead');
return new Mtk.Rectangle(params);
};
Gio.File.prototype.touch_async = function (callback) {
Shell.util_touch_file_async(this, callback);
};
Gio.File.prototype.touch_finish = function (result) {
return Shell.util_touch_file_finish(this, result);
};
const origToString = Object.prototype.toString;
Object.prototype.toString = function () {
let base = origToString.call(this);
try {
if ('actor' in this && this.actor instanceof Clutter.Actor)
return base.replace(/\]$/, ` delegate for ${this.actor.toString().substring(1)}`);
else
return base;
} catch {
return base;
}
};
const slowdownEnv = GLib.getenv('GNOME_SHELL_SLOWDOWN_FACTOR');
if (slowdownEnv) {
let factor = parseFloat(slowdownEnv);
if (!isNaN(factor) && factor > 0.0)
St.Settings.get().slow_down_factor = factor;
}
function wrapSpawnFunction(func) {
const originalFunc = GLib[func];
return function (workingDirectory, argv, envp, flags, childSetup, ...args) {
const commonArgs = [workingDirectory, argv, envp, flags];
if (childSetup) {
logError(new Error(`Using child GLib.${func} with a GLib.SpawnChildSetupFunc ` +
'is unsafe and may dead-lock, thus it should never be used from JavaScript. ' +
`Shell.${func} can be used to perform default actions or an ` +
'async-signal-safe alternative should be used instead'));
return originalFunc(...commonArgs, childSetup, ...args);
}
const retValue = Shell[`util_${func}`](...commonArgs, ...args);
return [true, ...Array.isArray(retValue) ? retValue : [retValue]];
};
}
GLib.spawn_async = wrapSpawnFunction('spawn_async');
GLib.spawn_async_with_pipes = wrapSpawnFunction('spawn_async_with_pipes');
GLib.spawn_async_with_fds = wrapSpawnFunction('spawn_async_with_fds');
GLib.spawn_async_with_pipes_and_fds = wrapSpawnFunction('spawn_async_with_pipes_and_fds');
// OK, now things are initialized enough that we can import shell JS
const Format = imports.format;
String.prototype.format = Format.format;
Math.clamp = function (x, lower, upper) {
return Math.min(Math.max(x, lower), upper);
};
// Prevent extensions from opening a display connection to ourselves
Gdk.set_allowed_backends('');

View file

@ -0,0 +1,347 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Soup from 'gi://Soup';
import * as Config from '../misc/config.js';
import * as Dialog from './dialog.js';
import * as ExtensionUtils from '../misc/extensionUtils.js';
import * as FileUtils from '../misc/fileUtils.js';
import * as Main from './main.js';
import * as ModalDialog from './modalDialog.js';
import {ExtensionErrors, ExtensionError} from '../misc/dbusErrors.js';
Gio._promisify(Soup.Session.prototype, 'send_and_read_async');
Gio._promisify(Gio.OutputStream.prototype, 'write_bytes_async');
Gio._promisify(Gio.IOStream.prototype, 'close_async');
Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
const REPOSITORY_URL_DOWNLOAD = 'https://extensions.gnome.org/download-extension/%s.shell-extension.zip';
const REPOSITORY_URL_INFO = 'https://extensions.gnome.org/extension-info/';
const REPOSITORY_URL_UPDATE = 'https://extensions.gnome.org/update-info/';
let _httpSession;
/**
* @param {string} uuid - extension uuid
* @param {Gio.DBusMethodInvocation} invocation - the caller
* @returns {void}
*/
export async function installExtension(uuid, invocation) {
if (!global.settings.get_boolean('allow-extension-installation')) {
invocation.return_error_literal(
ExtensionErrors, ExtensionError.NOT_ALLOWED,
'Extension installation is not allowed');
return;
}
const params = {
uuid,
shell_version: Config.PACKAGE_VERSION,
};
const message = Soup.Message.new_from_encoded_form('GET',
REPOSITORY_URL_INFO,
Soup.form_encode_hash(params));
let info;
try {
const bytes = await _httpSession.send_and_read_async(
message,
GLib.PRIORITY_DEFAULT,
null);
checkResponse(message);
const decoder = new TextDecoder();
info = JSON.parse(decoder.decode(bytes.get_data()));
} catch (e) {
Main.extensionManager.logExtensionError(uuid, e);
invocation.return_error_literal(
ExtensionErrors, ExtensionError.INFO_DOWNLOAD_FAILED,
e.message);
return;
}
const dialog = new InstallExtensionDialog(uuid, info, invocation);
dialog.open();
}
/**
* @param {string} uuid
*/
export function uninstallExtension(uuid) {
let extension = Main.extensionManager.lookup(uuid);
if (!extension)
return false;
// Don't try to uninstall system extensions
if (extension.type !== ExtensionUtils.ExtensionType.PER_USER)
return false;
if (!Main.extensionManager.unloadExtension(extension))
return false;
FileUtils.recursivelyDeleteDir(extension.dir, true);
try {
const updatesDir = Gio.File.new_for_path(GLib.build_filenamev(
[global.userdatadir, 'extension-updates', extension.uuid]));
FileUtils.recursivelyDeleteDir(updatesDir, true);
} catch {
// not an error
}
return true;
}
/**
* Check return status of reponse
*
* @param {Soup.Message} message - an http response
* @returns {void}
* @throws
*/
function checkResponse(message) {
const {statusCode} = message;
const phrase = Soup.Status.get_phrase(statusCode);
if (statusCode !== Soup.Status.OK)
throw new Error(`Unexpected response: ${phrase}`);
}
/**
* @param {GLib.Bytes} bytes - archive data
* @param {Gio.File} dir - target directory
* @returns {void}
*/
async function extractExtensionArchive(bytes, dir) {
if (!dir.query_exists(null))
dir.make_directory_with_parents(null);
const [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip');
await stream.output_stream.write_bytes_async(bytes,
GLib.PRIORITY_DEFAULT, null);
stream.close_async(GLib.PRIORITY_DEFAULT, null);
const unzip = Gio.Subprocess.new(
['unzip', '-uod', dir.get_path(), '--', file.get_path()],
Gio.SubprocessFlags.NONE);
await unzip.wait_check_async(null);
const schemasPath = dir.get_child('schemas');
try {
const info = await schemasPath.query_info_async(
Gio.FILE_ATTRIBUTE_STANDARD_TYPE,
Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT,
null);
if (info.get_file_type() !== Gio.FileType.DIRECTORY)
throw new Error('schemas is not a directory');
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
console.warn(`Error while looking for schema for extension ${dir.get_basename()}: ${e.message}`);
return;
}
const compileSchema = Gio.Subprocess.new(
['glib-compile-schemas', '--strict', schemasPath.get_path()],
Gio.SubprocessFlags.NONE);
try {
await compileSchema.wait_check_async(null);
} catch (e) {
log(`Error while compiling schema for extension ${dir.get_basename()}: (${e.message})`);
}
}
/**
* @param {string} uuid - extension uuid
* @returns {void}
*/
export async function downloadExtensionUpdate(uuid) {
if (!Main.extensionManager.updatesSupported)
return;
const dir = Gio.File.new_for_path(
GLib.build_filenamev([global.userdatadir, 'extension-updates', uuid]));
const params = {shell_version: Config.PACKAGE_VERSION};
const message = Soup.Message.new_from_encoded_form('GET',
REPOSITORY_URL_DOWNLOAD.format(uuid),
Soup.form_encode_hash(params));
try {
const bytes = await _httpSession.send_and_read_async(
message,
GLib.PRIORITY_DEFAULT,
null);
checkResponse(message);
await extractExtensionArchive(bytes, dir);
Main.extensionManager.notifyExtensionUpdate(uuid);
} catch (e) {
log(`Error while downloading update for extension ${uuid}: (${e.message})`);
}
}
/**
* Check extensions.gnome.org for updates
*
* @returns {void}
*/
export async function checkForUpdates() {
if (!Main.extensionManager.updatesSupported)
return;
let metadatas = {};
Main.extensionManager.getUuids().forEach(uuid => {
let extension = Main.extensionManager.lookup(uuid);
if (extension.type !== ExtensionUtils.ExtensionType.PER_USER)
return;
if (extension.hasUpdate)
return;
metadatas[uuid] = {
version: extension.metadata.version,
};
});
if (Object.keys(metadatas).length === 0)
return; // nothing to update
const versionCheck = global.settings.get_boolean(
'disable-extension-version-validation');
const params = {
shell_version: Config.PACKAGE_VERSION,
disable_version_validation: `${versionCheck}`,
};
const requestBody = new GLib.Bytes(JSON.stringify(metadatas));
const message = Soup.Message.new('POST',
`${REPOSITORY_URL_UPDATE}?${Soup.form_encode_hash(params)}`);
message.set_request_body_from_bytes('application/json', requestBody);
let json;
try {
const bytes = await _httpSession.send_and_read_async(
message,
GLib.PRIORITY_DEFAULT,
null);
checkResponse(message);
json = new TextDecoder().decode(bytes.get_data());
} catch (e) {
log(`Update check failed: ${e.message}`);
return;
}
const operations = JSON.parse(json);
const updates = [];
for (const uuid in operations) {
const operation = operations[uuid];
if (operation === 'upgrade' || operation === 'downgrade')
updates.push(uuid);
}
try {
await Promise.allSettled(
updates.map(uuid => downloadExtensionUpdate(uuid)));
} catch (e) {
log(`Some extension updates failed to download: ${e.message}`);
}
}
class ExtractError extends Error {
get name() {
return 'ExtractError';
}
}
class EnableError extends Error {
get name() {
return 'EnableError';
}
}
const InstallExtensionDialog = GObject.registerClass(
class InstallExtensionDialog extends ModalDialog.ModalDialog {
_init(uuid, info, invocation) {
super._init({styleClass: 'extension-dialog'});
this._uuid = uuid;
this._info = info;
this._invocation = invocation;
this.setButtons([{
label: _('Cancel'),
action: this._onCancelButtonPressed.bind(this),
key: Clutter.KEY_Escape,
}, {
label: _('Install'),
action: this._onInstallButtonPressed.bind(this),
default: true,
}]);
let content = new Dialog.MessageDialogContent({
title: _('Install Extension'),
description: _('Download and install “%s” from extensions.gnome.org?').format(info.name),
});
this.contentLayout.add_child(content);
}
_onCancelButtonPressed() {
this.close();
this._invocation.return_value(GLib.Variant.new('(s)', ['cancelled']));
}
async _onInstallButtonPressed() {
this.close();
const params = {shell_version: Config.PACKAGE_VERSION};
const message = Soup.Message.new_from_encoded_form('GET',
REPOSITORY_URL_DOWNLOAD.format(this._uuid),
Soup.form_encode_hash(params));
const dir = Gio.File.new_for_path(
GLib.build_filenamev([global.userdatadir, 'extensions', this._uuid]));
try {
const bytes = await _httpSession.send_and_read_async(
message,
GLib.PRIORITY_DEFAULT,
null);
checkResponse(message);
try {
await extractExtensionArchive(bytes, dir);
} catch (e) {
throw new ExtractError(e.message);
}
const extension = Main.extensionManager.createExtensionObject(
this._uuid, dir, ExtensionUtils.ExtensionType.PER_USER);
Main.extensionManager.loadExtension(extension);
if (!Main.extensionManager.enableExtension(this._uuid))
throw new EnableError(`Cannot enable ${this._uuid}`);
this._invocation.return_value(new GLib.Variant('(s)', ['successful']));
} catch (e) {
let code;
if (e instanceof ExtractError)
code = ExtensionError.EXTRACT_FAILED;
else if (e instanceof EnableError)
code = ExtensionError.ENABLE_FAILED;
else
code = ExtensionError.DOWNLOAD_FAILED;
log(`Error while installing ${this._uuid}: ${e.message}`);
this._invocation.return_error_literal(
ExtensionErrors, code, e.message);
}
}
});
export function init() {
_httpSession = new Soup.Session();
}

812
js/ui/extensionSystem.js Normal file
View file

@ -0,0 +1,812 @@
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import St from 'gi://St';
import Shell from 'gi://Shell';
import * as Signals from '../misc/signals.js';
import * as Config from '../misc/config.js';
import * as ExtensionDownloader from './extensionDownloader.js';
import {formatError} from '../misc/errorUtils.js';
import {
ExtensionState, ExtensionType, loadExtensionMetadata
} from '../misc/extensionUtils.js';
import * as FileUtils from '../misc/fileUtils.js';
import * as Main from './main.js';
import * as MessageTray from './messageTray.js';
const ENABLED_EXTENSIONS_KEY = 'enabled-extensions';
const DISABLED_EXTENSIONS_KEY = 'disabled-extensions';
const DISABLE_USER_EXTENSIONS_KEY = 'disable-user-extensions';
const EXTENSION_DISABLE_VERSION_CHECK_KEY = 'disable-extension-version-validation';
const UPDATE_CHECK_TIMEOUT = 24 * 60 * 60; // 1 day in seconds
function stateToString(state) {
return Object.keys(ExtensionState).find(k => ExtensionState[k] === state);
}
export class ExtensionManager extends Signals.EventEmitter {
constructor() {
super();
this._initializationPromise = null;
this._updateNotified = false;
this._updateInProgress = false;
this._updatedUUIDS = [];
this._extensions = new Map();
this._unloadedExtensions = new Map();
this._enabledExtensions = [];
this._extensionOrder = [];
this._checkVersion = false;
St.Settings.get().connect('notify::color-scheme',
() => this._reloadExtensionStylesheets());
Main.sessionMode.connect('updated', () => {
this._sessionUpdated().catch(logError);
});
}
init() {
// The following file should exist for a period of time when extensions
// are enabled after start. If it exists, then the systemd unit will
// disable extensions should gnome-shell crash.
// Should the file already exist from a previous login, then this is OK.
let disableFilename = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gnome-shell-disable-extensions']);
let disableFile = Gio.File.new_for_path(disableFilename);
try {
disableFile.create(Gio.FileCreateFlags.REPLACE_DESTINATION, null);
} catch (e) {
log(`Failed to create file ${disableFilename}: ${e.message}`);
}
const shutdownId = global.connect('shutdown',
() => disableFile.delete(null));
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, () => {
global.disconnect(shutdownId);
disableFile.delete(null);
return GLib.SOURCE_REMOVE;
});
this._installExtensionUpdates();
this._sessionUpdated().then(() => {
ExtensionDownloader.checkForUpdates();
}).catch(logError);
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, UPDATE_CHECK_TIMEOUT, () => {
ExtensionDownloader.checkForUpdates();
return GLib.SOURCE_CONTINUE;
});
}
get updatesSupported() {
const appSys = Shell.AppSystem.get_default();
const hasUpdatesApp =
appSys.lookup_app('org.gnome.Extensions.desktop') !== null ||
appSys.lookup_app('com.mattjakeman.ExtensionManager.desktop') !== null;
const allowed = global.settings.get_boolean('allow-extension-installation');
return allowed && hasUpdatesApp;
}
lookup(uuid) {
return this._extensions.get(uuid);
}
getUuids() {
return [...this._extensions.keys()];
}
_reloadExtensionStylesheets() {
for (const ext of this._extensions.values()) {
// No stylesheet, nothing to reload
if (!ext.stylesheet)
continue;
// No variants, so skip reloading
const path = ext.stylesheet.get_path();
if (!path.endsWith('-dark.css') && !path.endsWith('-light.css'))
continue;
try {
this._unloadExtensionStylesheet(ext);
this._loadExtensionStylesheet(ext);
} catch (e) {
this._callExtensionDisable(ext.uuid);
this.logExtensionError(ext.uuid, e);
}
}
}
_loadExtensionStylesheet(extension) {
if (extension.state !== ExtensionState.ACTIVE &&
extension.state !== ExtensionState.ACTIVATING)
return;
const variant = Main.getStyleVariant();
const stylesheetNames = [
`${global.sessionMode}-${variant}.css`,
`stylesheet-${variant}.css`,
`${global.sessionMode}.css`,
'stylesheet.css',
];
const theme = St.ThemeContext.get_for_stage(global.stage).get_theme();
for (const name of stylesheetNames) {
try {
const stylesheetFile = extension.dir.get_child(name);
theme.load_stylesheet(stylesheetFile);
extension.stylesheet = stylesheetFile;
break;
} catch (e) {
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
continue; // not an error
throw e;
}
}
}
_unloadExtensionStylesheet(extension) {
if (!extension.stylesheet)
return;
const theme = St.ThemeContext.get_for_stage(global.stage).get_theme();
theme.unload_stylesheet(extension.stylesheet);
delete extension.stylesheet;
}
_changeExtensionState(extension, newState) {
const strState = stateToString(newState);
console.debug(`Changing state of extension ${extension.uuid} to ${strState}`);
extension.state = newState;
this.emit('extension-state-changed', extension);
}
_extensionSupportsSessionMode(uuid) {
const extension = this.lookup(uuid);
if (!extension)
return false;
if (extension.sessionModes.includes(Main.sessionMode.currentMode))
return true;
if (extension.sessionModes.includes(Main.sessionMode.parentMode))
return true;
return false;
}
async _callExtensionDisable(uuid) {
let extension = this.lookup(uuid);
if (!extension)
return;
if (extension.state !== ExtensionState.ACTIVE)
return;
this._changeExtensionState(extension, ExtensionState.DEACTIVATING);
// "Rebase" the extension order by disabling and then enabling extensions
// in order to help prevent conflicts.
// Example:
// order = [A, B, C, D, E]
// user disables C
// this should: disable E, disable D, disable C, enable D, enable E
let orderIdx = this._extensionOrder.indexOf(uuid);
let order = this._extensionOrder.slice(orderIdx + 1);
let orderReversed = order.slice().reverse();
for (let i = 0; i < orderReversed.length; i++) {
let otherUuid = orderReversed[i];
try {
console.debug(`Temporarily disable extension ${otherUuid}`);
this.lookup(otherUuid).stateObj.disable();
} catch (e) {
this.logExtensionError(otherUuid, e);
}
}
try {
extension.stateObj.disable();
} catch (e) {
this.logExtensionError(uuid, e);
}
this._unloadExtensionStylesheet(extension);
for (let i = 0; i < order.length; i++) {
let otherUuid = order[i];
try {
console.debug(`Re-enable extension ${otherUuid}`);
// eslint-disable-next-line no-await-in-loop
await this.lookup(otherUuid).stateObj.enable();
} catch (e) {
this.logExtensionError(otherUuid, e);
}
}
this._extensionOrder.splice(orderIdx, 1);
if (extension.state !== ExtensionState.ERROR)
this._changeExtensionState(extension, ExtensionState.INACTIVE);
}
async _callExtensionEnable(uuid) {
if (!this._extensionSupportsSessionMode(uuid))
return;
let extension = this.lookup(uuid);
if (!extension)
return;
if (extension.state === ExtensionState.INITIALIZED)
await this._callExtensionInit(uuid);
if (extension.state !== ExtensionState.INACTIVE)
return;
this._changeExtensionState(extension, ExtensionState.ACTIVATING);
try {
this._loadExtensionStylesheet(extension);
} catch (e) {
this.logExtensionError(uuid, e);
return;
}
try {
await extension.stateObj.enable();
this._changeExtensionState(extension, ExtensionState.ACTIVE);
this._extensionOrder.push(uuid);
} catch (e) {
this._unloadExtensionStylesheet(extension);
this.logExtensionError(uuid, e);
}
}
enableExtension(uuid) {
if (!this._extensions.has(uuid))
return false;
let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
if (disabledExtensions.includes(uuid)) {
disabledExtensions = disabledExtensions.filter(item => item !== uuid);
global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions);
}
if (!enabledExtensions.includes(uuid)) {
enabledExtensions.push(uuid);
global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions);
}
return true;
}
disableExtension(uuid) {
if (!this._extensions.has(uuid))
return false;
let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
if (enabledExtensions.includes(uuid)) {
enabledExtensions = enabledExtensions.filter(item => item !== uuid);
global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions);
}
if (!disabledExtensions.includes(uuid)) {
disabledExtensions.push(uuid);
global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions);
}
return true;
}
openExtensionPrefs(uuid, parentWindow, options) {
const extension = this.lookup(uuid);
if (!extension || !extension.hasPrefs)
return false;
Gio.DBus.session.call(
'org.gnome.Shell.Extensions',
'/org/gnome/Shell/Extensions',
'org.gnome.Shell.Extensions',
'OpenExtensionPrefs',
new GLib.Variant('(ssa{sv})', [uuid, parentWindow, options]),
null,
Gio.DBusCallFlags.NONE,
-1,
null);
return true;
}
notifyExtensionUpdate(uuid) {
if (this._updateInProgress) {
this._updatedUUIDS.push(uuid);
return;
}
let extension = this.lookup(uuid);
if (!extension)
return;
extension.hasUpdate = true;
this.emit('extension-state-changed', extension);
if (!this._updateNotified) {
this._updateNotified = true;
let source = new ExtensionUpdateSource();
Main.messageTray.add(source);
const notification = new MessageTray.Notification({
source,
title: _('Extension Updates Available'),
body: _('Extension updates are ready to be installed'),
});
notification.connect('activated',
() => source.open());
source.addNotification(notification);
}
}
logExtensionError(uuid, error) {
let extension = this.lookup(uuid);
if (!extension)
return;
const message = formatError(error, {showStack: false});
console.debug(`Changing state of extension ${uuid} to ERROR`);
extension.error = message;
extension.state = ExtensionState.ERROR;
if (!extension.errors)
extension.errors = [];
extension.errors.push(message);
logError(error, `Extension ${uuid}`);
this.emit('extension-state-changed', extension);
}
createExtensionObject(uuid, dir, type) {
const metadata = loadExtensionMetadata(uuid, dir);
const extension = {
metadata,
uuid,
type,
dir,
path: dir.get_path(),
error: '',
hasPrefs: dir.get_child('prefs.js').query_exists(null),
enabled: this._enabledExtensions.includes(uuid),
hasUpdate: false,
canChange: false,
sessionModes: metadata['session-modes'] ?? ['user'],
};
this._extensions.set(uuid, extension);
return extension;
}
_canLoad(extension) {
if (!this._unloadedExtensions.has(extension.uuid))
return true;
const version = this._unloadedExtensions.get(extension.uuid);
return extension.metadata.version === version;
}
_isOutOfDate(extension) {
const [major] = Config.PACKAGE_VERSION.split('.');
return !extension.metadata['shell-version'].some(v => v.startsWith(major));
}
async loadExtension(extension) {
const {uuid} = extension;
console.debug(`Loading extension ${uuid}`);
// Default to error, we set success as the last step
extension.state = ExtensionState.ERROR;
if (this._checkVersion && this._isOutOfDate(extension)) {
extension.state = ExtensionState.OUT_OF_DATE;
} else if (!this._canLoad(extension)) {
this.logExtensionError(uuid, new Error(
'A different version was loaded previously. You need to log out for changes to take effect.'));
} else {
const enabled = this._enabledExtensions.includes(uuid) &&
this._extensionSupportsSessionMode(uuid);
if (enabled) {
if (!await this._callExtensionInit(uuid))
return;
if (extension.state === ExtensionState.INACTIVE)
await this._callExtensionEnable(uuid);
} else {
extension.state = ExtensionState.INITIALIZED;
}
this._unloadedExtensions.delete(uuid);
}
console.debug(`Extension ${uuid} in state ${stateToString(extension.state)} after loading`);
this._updateCanChange(extension);
this.emit('extension-state-changed', extension);
}
async unloadExtension(extension) {
const {uuid, type} = extension;
// Try to disable it -- if it's ERROR'd, we can't guarantee that,
// but it will be removed on next reboot, and hopefully nothing
// broke too much.
await this._callExtensionDisable(uuid);
this._changeExtensionState(extension, ExtensionState.UNINSTALLED);
// The extension is now cached and it's impossible to load a different version
if (type === ExtensionType.PER_USER && extension.isImported)
this._unloadedExtensions.set(uuid, extension.metadata.version);
this._extensions.delete(uuid);
return true;
}
async reloadExtension(oldExtension) {
// Grab the things we'll need to pass to createExtensionObject
// to reload it.
let {uuid, dir, type} = oldExtension;
// Then unload the old extension.
await this.unloadExtension(oldExtension);
// Now, recreate the extension and load it.
let newExtension;
try {
newExtension = this.createExtensionObject(uuid, dir, type);
} catch (e) {
this.logExtensionError(uuid, e);
return;
}
await this.loadExtension(newExtension);
}
async _callExtensionInit(uuid) {
if (!this._extensionSupportsSessionMode(uuid))
return false;
let extension = this.lookup(uuid);
if (!extension)
throw new Error('Extension was not properly created. Call createExtensionObject first');
let dir = extension.dir;
let extensionJs = dir.get_child('extension.js');
if (!extensionJs.query_exists(null)) {
this.logExtensionError(uuid, new Error('Missing extension.js'));
return false;
}
let extensionModule;
let extensionState = null;
try {
extensionModule = await import(extensionJs.get_uri());
// Extensions can only be imported once, so add a property to avoid
// attempting to re-import an extension.
extension.isImported = true;
} catch (e) {
this.logExtensionError(uuid, e);
return false;
}
try {
const {metadata, path} = extension;
extensionState =
new extensionModule.default({...metadata, dir, path});
} catch (e) {
this.logExtensionError(uuid, e);
return false;
}
extension.stateObj = extensionState;
this._changeExtensionState(extension, ExtensionState.INACTIVE);
return true;
}
_getModeExtensions() {
if (Array.isArray(Main.sessionMode.enabledExtensions))
return Main.sessionMode.enabledExtensions;
return [];
}
_updateCanChange(extension) {
let isMode = this._getModeExtensions().includes(extension.uuid);
let modeOnly = global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY);
let changeKey = isMode
? DISABLE_USER_EXTENSIONS_KEY
: ENABLED_EXTENSIONS_KEY;
extension.canChange =
global.settings.is_writable(changeKey) &&
(isMode || !modeOnly);
}
_getEnabledExtensions() {
let extensions = this._getModeExtensions();
if (!global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY))
extensions = extensions.concat(global.settings.get_strv(ENABLED_EXTENSIONS_KEY));
extensions.sort((a, b) => this._compareExtensions(this.lookup(a), this.lookup(b)));
// filter out 'disabled-extensions' which takes precedence
let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
return extensions.filter(item => !disabledExtensions.includes(item));
}
async _onUserExtensionsEnabledChanged() {
await this._onEnabledExtensionsChanged();
this._onSettingsWritableChanged();
}
async _onEnabledExtensionsChanged() {
let newEnabledExtensions = this._getEnabledExtensions();
for (const extension of this._extensions.values()) {
const wasEnabled = extension.enabled;
extension.enabled = newEnabledExtensions.includes(extension.uuid);
if (wasEnabled !== extension.enabled)
this.emit('extension-state-changed', extension);
}
// Find and enable all the newly enabled extensions: UUIDs found in the
// new setting, but not in the old one.
const extensionsToEnable = newEnabledExtensions
.filter(uuid => !this._enabledExtensions.includes(uuid) &&
this._extensionSupportsSessionMode(uuid));
for (const uuid of extensionsToEnable) {
// eslint-disable-next-line no-await-in-loop
await this._callExtensionEnable(uuid);
}
// Find and disable all the newly disabled extensions: UUIDs found in the
// old setting, but not in the new one.
const extensionsToDisable = this._extensionOrder
.filter(uuid => !newEnabledExtensions.includes(uuid) ||
!this._extensionSupportsSessionMode(uuid));
// Reverse mutates the original array, but .filter() creates a new array.
extensionsToDisable.reverse();
for (const uuid of extensionsToDisable) {
// eslint-disable-next-line no-await-in-loop
await this._callExtensionDisable(uuid);
}
this._enabledExtensions = newEnabledExtensions;
}
_onSettingsWritableChanged() {
for (let extension of this._extensions.values()) {
this._updateCanChange(extension);
this.emit('extension-state-changed', extension);
}
}
async _onVersionValidationChanged() {
const checkVersion = !global.settings.get_boolean(EXTENSION_DISABLE_VERSION_CHECK_KEY);
if (checkVersion === this._checkVersion)
return;
this._checkVersion = checkVersion;
// Disabling extensions modifies the order array, so use a copy
let extensionOrder = this._extensionOrder.slice();
// Disable enabled extensions first to avoid
// the "rebasing" done in _callExtensionDisable...
this._disableAllExtensions();
// ...and then reload and enable extensions in the correct order again.
const extensionsToReload = [...this._extensions.values()].sort((a, b) => {
return extensionOrder.indexOf(a.uuid) - extensionOrder.indexOf(b.uuid);
});
for (const extension of extensionsToReload) {
// eslint-disable-next-line no-await-in-loop
await this.reloadExtension(extension);
}
}
async _handleMajorUpdate() {
const [majorVersion] = Config.PACKAGE_VERSION.split('.');
const path = `${global.userdatadir}/update-check-${majorVersion}`;
const file = Gio.File.new_for_path(path);
try {
if (!await file.touch_async())
return;
} catch (e) {
logError(e);
}
this._updateInProgress = true;
await ExtensionDownloader.checkForUpdates();
this._installExtensionUpdates();
this._updatedUUIDS.map(uuid => this.lookup(uuid)).forEach(
ext => this.reloadExtension(ext));
this._updatedUUIDS = [];
this._updateInProgress = false;
}
_installExtensionUpdates() {
if (!this.updatesSupported)
return;
for (const {dir, info} of FileUtils.collectFromDatadirs('extension-updates', true)) {
let fileType = info.get_file_type();
if (fileType !== Gio.FileType.DIRECTORY)
continue;
let uuid = info.get_name();
let extensionDir = Gio.File.new_for_path(
GLib.build_filenamev([global.userdatadir, 'extensions', uuid]));
try {
FileUtils.recursivelyDeleteDir(extensionDir, false);
FileUtils.recursivelyMoveDir(dir, extensionDir);
} catch {
log(`Failed to install extension updates for ${uuid}`);
}
try {
FileUtils.recursivelyDeleteDir(dir, true);
} catch (e) {
console.error(`Failed to delete extension update: ${e.message}`);
}
}
}
_compareExtensions(a, b) {
const modesA = a?.sessionModes ?? [];
const modesB = b?.sessionModes ?? [];
return modesB.length - modesA.length;
}
async _loadExtensions() {
global.settings.connect(`changed::${ENABLED_EXTENSIONS_KEY}`, () => {
this._onEnabledExtensionsChanged();
});
global.settings.connect(`changed::${DISABLED_EXTENSIONS_KEY}`, () => {
this._onEnabledExtensionsChanged();
});
global.settings.connect(`changed::${DISABLE_USER_EXTENSIONS_KEY}`, () => {
this._onUserExtensionsEnabledChanged();
});
global.settings.connect(`changed::${EXTENSION_DISABLE_VERSION_CHECK_KEY}`, () => {
this._onVersionValidationChanged();
});
global.settings.connect(`writable-changed::${ENABLED_EXTENSIONS_KEY}`, () =>
this._onSettingsWritableChanged());
global.settings.connect(`writable-changed::${DISABLED_EXTENSIONS_KEY}`, () =>
this._onSettingsWritableChanged());
await this._onVersionValidationChanged();
this._enabledExtensions = this._getEnabledExtensions();
let perUserDir = Gio.File.new_for_path(global.userdatadir);
const includeUserDir = global.settings.get_boolean('allow-extension-installation');
const extensionFiles = [...FileUtils.collectFromDatadirs('extensions', includeUserDir)];
const extensionObjects = extensionFiles.map(({dir, info}) => {
let fileType = info.get_file_type();
if (fileType !== Gio.FileType.DIRECTORY)
return null;
let uuid = info.get_name();
let existing = this.lookup(uuid);
if (existing) {
log(`Extension ${uuid} already installed in ${existing.path}. ${dir.get_path()} will not be loaded`);
return null;
}
let extension;
let type = dir.has_prefix(perUserDir)
? ExtensionType.PER_USER
: ExtensionType.SYSTEM;
try {
extension = this.createExtensionObject(uuid, dir, type);
} catch (error) {
logError(error, `Could not load extension ${uuid}`);
return null;
}
return extension;
}).filter(extension => extension !== null).sort(this._compareExtensions.bind(this));
// after updating to a new major version,
// update extensions before loading them
await this._handleMajorUpdate();
for (const extension of extensionObjects) {
// eslint-disable-next-line no-await-in-loop
await this.loadExtension(extension);
}
}
async _enableAllExtensions() {
if (!this._initializationPromise)
this._initializationPromise = this._loadExtensions();
await this._initializationPromise;
for (const uuid of this._enabledExtensions) {
// eslint-disable-next-line no-await-in-loop
await this._callExtensionEnable(uuid);
}
}
/**
* Disables all currently enabled extensions.
*/
async _disableAllExtensions() {
// Wait for extensions to finish loading before starting
// to disable, otherwise some extensions may enable after
// this function.
if (this._initializationPromise)
await this._initializationPromise;
const extensionsToDisable = this._extensionOrder.slice();
// Extensions are disabled in the reverse order
// from when they were enabled.
extensionsToDisable.reverse();
for (const uuid of extensionsToDisable) {
// eslint-disable-next-line no-await-in-loop
await this._callExtensionDisable(uuid);
}
}
async _sessionUpdated() {
// Take care of added or removed sessionMode extensions
await this._onEnabledExtensionsChanged();
await this._enableAllExtensions();
}
}
const ExtensionUpdateSource = GObject.registerClass(
class ExtensionUpdateSource extends MessageTray.Source {
constructor() {
const appSys = Shell.AppSystem.get_default();
const app =
appSys.lookup_app('org.gnome.Extensions.desktop') ||
appSys.lookup_app('com.mattjakeman.ExtensionManager.desktop');
super({
title: app.get_name(),
icon: app.get_icon(),
policy: MessageTray.NotificationPolicy.newForApp(app),
});
this._app = app;
}
open() {
this._app.activate();
Main.overview.hide();
Main.panel.closeCalendar();
}
});

View file

@ -0,0 +1,89 @@
/*
* Copyright 2012 Inclusive Design Research Centre, OCAD University.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
* Author:
* Joseph Scheuhammer <clown@alum.mit.edu>
* Contributor:
* Magdalen Berns <m.berns@sms.ed.ac.uk>
*/
import Atspi from 'gi://Atspi';
import * as Signals from '../misc/signals.js';
const CARETMOVED = 'object:text-caret-moved';
const STATECHANGED = 'object:state-changed';
export class FocusCaretTracker extends Signals.EventEmitter {
constructor() {
super();
this._atspiListener = Atspi.EventListener.new(this._onChanged.bind(this));
this._atspiInited = false;
this._focusListenerRegistered = false;
this._caretListenerRegistered = false;
}
_onChanged(event) {
if (event.type.indexOf(STATECHANGED) === 0)
this.emit('focus-changed', event);
else if (event.type === CARETMOVED)
this.emit('caret-moved', event);
}
_initAtspi() {
if (!this._atspiInited && Atspi.init() === 0) {
Atspi.set_timeout(250, 250);
this._atspiInited = true;
}
return this._atspiInited;
}
registerFocusListener() {
if (!this._initAtspi() || this._focusListenerRegistered)
return;
this._atspiListener.register(`${STATECHANGED}:focused`);
this._atspiListener.register(`${STATECHANGED}:selected`);
this._focusListenerRegistered = true;
}
registerCaretListener() {
if (!this._initAtspi() || this._caretListenerRegistered)
return;
this._atspiListener.register(CARETMOVED);
this._caretListenerRegistered = true;
}
deregisterFocusListener() {
if (!this._focusListenerRegistered)
return;
this._atspiListener.deregister(`${STATECHANGED}:focused`);
this._atspiListener.deregister(`${STATECHANGED}:selected`);
this._focusListenerRegistered = false;
}
deregisterCaretListener() {
if (!this._caretListenerRegistered)
return;
this._atspiListener.deregister(CARETMOVED);
this._caretListenerRegistered = false;
}
}

293
js/ui/grabHelper.js Normal file
View file

@ -0,0 +1,293 @@
import Clutter from 'gi://Clutter';
import St from 'gi://St';
import * as Main from './main.js';
import * as Params from '../misc/params.js';
/**
* GrabHelper:
*
* Creates a new GrabHelper object, for dealing with keyboard and pointer grabs
* associated with a set of actors.
*
* Note that the grab can be automatically dropped at any time by the user, and
* your code just needs to deal with it; you shouldn't adjust behavior directly
* after you call ungrab(), but instead pass an 'onUngrab' callback when you
* call grab().
*/
export class GrabHelper {
/**
* @param {Clutter.Actor} owner the actor that owns the GrabHelper
* @param {*} params optional parameters to pass to Main.pushModal()
*/
constructor(owner, params) {
if (!(owner instanceof Clutter.Actor))
throw new Error('GrabHelper owner must be a Clutter.Actor');
this._owner = owner;
this._modalParams = params;
this._grabStack = [];
this._ignoreUntilRelease = false;
this._modalCount = 0;
}
_isWithinGrabbedActor(actor) {
let currentActor = this.currentGrab.actor;
while (actor) {
if (actor === currentActor)
return true;
actor = actor.get_parent();
}
return false;
}
get currentGrab() {
return this._grabStack[this._grabStack.length - 1] || {};
}
get grabbed() {
return this._grabStack.length > 0;
}
get grabStack() {
return this._grabStack;
}
_findStackIndex(actor) {
if (!actor)
return -1;
for (let i = 0; i < this._grabStack.length; i++) {
if (this._grabStack[i].actor === actor)
return i;
}
return -1;
}
_actorInGrabStack(actor) {
while (actor) {
let idx = this._findStackIndex(actor);
if (idx >= 0)
return idx;
actor = actor.get_parent();
}
return -1;
}
isActorGrabbed(actor) {
return this._findStackIndex(actor) >= 0;
}
// grab:
// @params: A bunch of parameters, see below
//
// The general effect of a "grab" is to ensure that the passed in actor
// and all actors inside the grab get exclusive control of the mouse and
// keyboard, with the grab automatically being dropped if the user tries
// to dismiss it. The actor is passed in through @params.actor.
//
// grab() can be called multiple times, with the scope of the grab being
// changed to a different actor every time. A nested grab does not have
// to have its grabbed actor inside the parent grab actors.
//
// Grabs can be automatically dropped if the user tries to dismiss it
// in one of two ways: the user clicking outside the currently grabbed
// actor, or the user typing the Escape key.
//
// If the user clicks outside the grabbed actors, and the clicked on
// actor is part of a previous grab in the stack, grabs will be popped
// until that grab is active. However, the click event will not be
// replayed to the actor.
//
// If the user types the Escape key, one grab from the grab stack will
// be popped.
//
// When a grab is popped by user interacting as described above, if you
// pass a callback as @params.onUngrab, it will be called with %true.
//
// If @params.focus is not null, we'll set the key focus directly
// to that actor instead of navigating in @params.actor. This is for
// use cases like menus, where we want to grab the menu actor, but keep
// focus on the clicked on menu item.
grab(params) {
params = Params.parse(params, {
actor: null,
focus: null,
onUngrab: null,
});
let focus = global.stage.key_focus;
let hadFocus = focus && this._isWithinGrabbedActor(focus);
let newFocus = params.actor;
if (this.isActorGrabbed(params.actor))
return true;
params.savedFocus = focus;
if (!this._takeModalGrab())
return false;
this._grabStack.push(params);
if (params.focus) {
params.focus.grab_key_focus();
} else if (newFocus && hadFocus) {
if (!newFocus.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
newFocus.grab_key_focus();
}
return true;
}
grabAsync(params) {
return new Promise((resolve, reject) => {
params.onUngrab = resolve;
if (!this.grab(params))
reject(new Error('Grab failed'));
});
}
_takeModalGrab() {
let firstGrab = this._modalCount === 0;
if (firstGrab) {
let grab = Main.pushModal(this._owner, this._modalParams);
if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
Main.popModal(grab);
return false;
}
this._grab = grab;
this._capturedEventId = this._owner.connect('captured-event',
(actor, event) => {
return this.onCapturedEvent(event);
});
}
this._modalCount++;
return true;
}
_releaseModalGrab() {
this._modalCount--;
if (this._modalCount > 0)
return;
this._owner.disconnect(this._capturedEventId);
this._ignoreUntilRelease = false;
Main.popModal(this._grab);
this._grab = null;
}
// ignoreRelease:
//
// Make sure that the next button release event evaluated by the
// capture event handler returns false. This is designed for things
// like the ComboBoxMenu that go away on press, but need to eat
// the next release event.
ignoreRelease() {
this._ignoreUntilRelease = true;
}
// ungrab:
// @params: The parameters for the grab; see below.
//
// Pops @params.actor from the grab stack, potentially dropping
// the grab. If the actor is not on the grab stack, this call is
// ignored with no ill effects.
//
// If the actor is not at the top of the grab stack, grabs are
// popped until the grabbed actor is at the top of the grab stack.
// The onUngrab callback for every grab is called for every popped
// grab with the parameter %false.
ungrab(params) {
params = Params.parse(params, {
actor: this.currentGrab.actor,
isUser: false,
});
let grabStackIndex = this._findStackIndex(params.actor);
if (grabStackIndex < 0)
return;
let focus = global.stage.key_focus;
let hadFocus = focus && this._isWithinGrabbedActor(focus);
let poppedGrabs = this._grabStack.slice(grabStackIndex);
// "Pop" all newly ungrabbed actors off the grab stack
// by truncating the array.
this._grabStack.length = grabStackIndex;
for (let i = poppedGrabs.length - 1; i >= 0; i--) {
let poppedGrab = poppedGrabs[i];
if (poppedGrab.onUngrab)
poppedGrab.onUngrab(params.isUser);
this._releaseModalGrab();
}
if (hadFocus) {
let poppedGrab = poppedGrabs[0];
if (poppedGrab.savedFocus)
poppedGrab.savedFocus.grab_key_focus();
}
}
onCapturedEvent(event) {
let type = event.type();
if (type === Clutter.EventType.KEY_PRESS &&
event.get_key_symbol() === Clutter.KEY_Escape) {
this.ungrab({isUser: true});
return Clutter.EVENT_STOP;
}
let motion = type === Clutter.EventType.MOTION;
let press = type === Clutter.EventType.BUTTON_PRESS;
let release = type === Clutter.EventType.BUTTON_RELEASE;
let button = press || release;
let touchUpdate = type === Clutter.EventType.TOUCH_UPDATE;
let touchBegin = type === Clutter.EventType.TOUCH_BEGIN;
let touchEnd = type === Clutter.EventType.TOUCH_END;
let touch = touchUpdate || touchBegin || touchEnd;
if (touch && !global.display.is_pointer_emulating_sequence(event.get_event_sequence()))
return Clutter.EVENT_PROPAGATE;
if (this._ignoreUntilRelease && (motion || release || touch)) {
if (release || touchEnd)
this._ignoreUntilRelease = false;
return Clutter.EVENT_PROPAGATE;
}
const targetActor = global.stage.get_event_actor(event);
if (type === Clutter.EventType.ENTER ||
type === Clutter.EventType.LEAVE ||
this.currentGrab.actor.contains(targetActor))
return Clutter.EVENT_PROPAGATE;
if (Main.keyboard.maybeHandleEvent(event))
return Clutter.EVENT_PROPAGATE;
if (button || touchBegin) {
// If we have a press event, ignore the next
// motion/release events.
if (press || touchBegin)
this._ignoreUntilRelease = true;
let i = this._actorInGrabStack(targetActor) + 1;
this.ungrab({actor: this._grabStack[i].actor, isUser: true});
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_STOP;
}
}

357
js/ui/ibusCandidatePopup.js Normal file
View file

@ -0,0 +1,357 @@
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import IBus from 'gi://IBus';
import Mtk from 'gi://Mtk';
import St from 'gi://St';
import * as BoxPointer from './boxpointer.js';
import * as Main from './main.js';
const MAX_CANDIDATES_PER_PAGE = 16;
const DEFAULT_INDEX_LABELS = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
'a', 'b', 'c', 'd', 'e', 'f',
];
const CandidateArea = GObject.registerClass({
Signals: {
'candidate-clicked': {
param_types: [
GObject.TYPE_UINT, GObject.TYPE_UINT, Clutter.ModifierType.$gtype,
],
},
'cursor-down': {},
'cursor-up': {},
'next-page': {},
'previous-page': {},
},
}, class CandidateArea extends St.BoxLayout {
_init() {
super._init({
orientation: Clutter.Orientation.VERTICAL,
reactive: true,
visible: false,
});
this._candidateBoxes = [];
for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) {
const box = new St.BoxLayout({
style_class: 'candidate-box',
reactive: true,
track_hover: true,
});
box._indexLabel = new St.Label({style_class: 'candidate-index'});
box._candidateLabel = new St.Label({style_class: 'candidate-label'});
box.add_child(box._indexLabel);
box.add_child(box._candidateLabel);
this._candidateBoxes.push(box);
this.add_child(box);
let j = i;
box.connect('button-release-event', (actor, event) => {
this.emit('candidate-clicked', j, event.get_button(), event.get_state());
return Clutter.EVENT_PROPAGATE;
});
}
this._buttonBox = new St.BoxLayout({style_class: 'candidate-page-button-box'});
this._previousButton = new St.Button({
style_class: 'candidate-page-button candidate-page-button-previous button',
x_expand: true,
});
this._buttonBox.add_child(this._previousButton);
this._nextButton = new St.Button({
style_class: 'candidate-page-button candidate-page-button-next button',
x_expand: true,
});
this._buttonBox.add_child(this._nextButton);
this.add_child(this._buttonBox);
this._previousButton.connect('clicked', () => {
this.emit('previous-page');
});
this._nextButton.connect('clicked', () => {
this.emit('next-page');
});
this._orientation = -1;
this._cursorPosition = 0;
}
vfunc_scroll_event(event) {
switch (event.get_scroll_direction()) {
case Clutter.ScrollDirection.UP:
this.emit('cursor-up');
break;
case Clutter.ScrollDirection.DOWN:
this.emit('cursor-down');
break;
}
return Clutter.EVENT_PROPAGATE;
}
setOrientation(orientation) {
if (this._orientation === orientation)
return;
this._orientation = orientation;
if (this._orientation === IBus.Orientation.HORIZONTAL) {
this.orientation = Clutter.Orientation.HORIZONTAL;
this.remove_style_class_name('vertical');
this.add_style_class_name('horizontal');
this._previousButton.icon_name = 'go-previous-symbolic';
this._nextButton.icon_name = 'go-next-symbolic';
} else { // VERTICAL || SYSTEM
this.orientation = Clutter.Orientation.VERTICAL;
this.add_style_class_name('vertical');
this.remove_style_class_name('horizontal');
this._previousButton.icon_name = 'go-up-symbolic';
this._nextButton.icon_name = 'go-down-symbolic';
}
}
setCandidates(indexes, candidates, cursorPosition, cursorVisible) {
for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) {
let visible = i < candidates.length;
let box = this._candidateBoxes[i];
box.visible = visible;
if (!visible)
continue;
box._indexLabel.text = indexes && indexes[i] ? indexes[i] : DEFAULT_INDEX_LABELS[i];
box._candidateLabel.text = candidates[i];
}
this._candidateBoxes[this._cursorPosition].remove_style_pseudo_class('selected');
this._cursorPosition = cursorPosition;
if (cursorVisible)
this._candidateBoxes[cursorPosition].add_style_pseudo_class('selected');
}
updateButtons(wrapsAround, page, nPages) {
if (nPages < 2) {
this._buttonBox.hide();
return;
}
this._buttonBox.show();
this._previousButton.reactive = wrapsAround || page > 0;
this._nextButton.reactive = wrapsAround || page < nPages - 1;
}
});
export const CandidatePopup = GObject.registerClass(
class IbusCandidatePopup extends BoxPointer.BoxPointer {
_init() {
super._init(St.Side.TOP);
this.visible = false;
this.style_class = 'candidate-popup-boxpointer';
this._dummyCursor = new Clutter.Actor({opacity: 0});
Main.layoutManager.uiGroup.add_child(this._dummyCursor);
Main.layoutManager.addTopChrome(this);
const box = new St.BoxLayout({
style_class: 'candidate-popup-content',
orientation: Clutter.Orientation.VERTICAL,
});
this.bin.set_child(box);
this._preeditText = new St.Label({
style_class: 'candidate-popup-text',
visible: false,
});
box.add_child(this._preeditText);
this._auxText = new St.Label({
style_class: 'candidate-popup-text',
visible: false,
});
box.add_child(this._auxText);
this._candidateArea = new CandidateArea();
box.add_child(this._candidateArea);
this._candidateArea.connect('previous-page', () => {
this._panelService.page_up();
});
this._candidateArea.connect('next-page', () => {
this._panelService.page_down();
});
this._candidateArea.connect('cursor-up', () => {
this._panelService.cursor_up();
});
this._candidateArea.connect('cursor-down', () => {
this._panelService.cursor_down();
});
this._candidateArea.connect('candidate-clicked', (area, index, button, state) => {
this._panelService.candidate_clicked(index, button, state);
});
this._panelService = null;
}
setPanelService(panelService) {
this._panelService = panelService;
if (!panelService)
return;
panelService.connect('set-cursor-location', (ps, x, y, w, h) => {
const focusWindow = global.display.focus_window;
let rect = new Mtk.Rectangle({x, y, width: w, height: h});
if (!global.stage.key_focus && focusWindow)
rect = focusWindow.protocol_to_stage_rect(rect);
this._setDummyCursorGeometry(
rect.x,
rect.y,
rect.width,
rect.height);
});
try {
panelService.connect('set-cursor-location-relative', (ps, x, y, w, h) => {
const focusWindow = global.display.focus_window;
if (!focusWindow)
return;
let rect = new Mtk.Rectangle({x, y, width: w, height: h});
rect = focusWindow.protocol_to_stage_rect(rect);
const windowActor = focusWindow.get_compositor_private();
this._setDummyCursorGeometry(
windowActor.x + rect.x,
windowActor.y + rect.y,
rect.width,
rect.height);
});
} catch {
// Only recent IBus versions have support for this signal
// which is used for wayland clients. In order to work
// with older IBus versions we can silently ignore the
// signal's absence.
}
panelService.connect('update-preedit-text', (ps, text, cursorPosition, visible) => {
this._preeditText.visible = visible;
this._updateVisibility();
this._preeditText.text = text.get_text();
let attrs = text.get_attributes();
if (attrs)
this._setTextAttributes(this._preeditText.clutter_text, attrs);
});
panelService.connect('show-preedit-text', () => {
this._preeditText.show();
this._updateVisibility();
});
panelService.connect('hide-preedit-text', () => {
this._preeditText.hide();
this._updateVisibility();
});
panelService.connect('update-auxiliary-text', (_ps, text, visible) => {
this._auxText.visible = visible;
this._updateVisibility();
this._auxText.text = text.get_text();
});
panelService.connect('show-auxiliary-text', () => {
this._auxText.show();
this._updateVisibility();
});
panelService.connect('hide-auxiliary-text', () => {
this._auxText.hide();
this._updateVisibility();
});
panelService.connect('update-lookup-table', (_ps, lookupTable, visible) => {
this._candidateArea.visible = visible;
this._updateVisibility();
let nCandidates = lookupTable.get_number_of_candidates();
let cursorPos = lookupTable.get_cursor_pos();
let pageSize = lookupTable.get_page_size();
let nPages = Math.ceil(nCandidates / pageSize);
let page = cursorPos === 0 ? 0 : Math.floor(cursorPos / pageSize);
let startIndex = page * pageSize;
let endIndex = Math.min((page + 1) * pageSize, nCandidates);
let indexes = [];
let indexLabel;
for (let i = 0; (indexLabel = lookupTable.get_label(i)); ++i)
indexes.push(indexLabel.get_text());
Main.keyboard.resetSuggestions();
Main.keyboard.setSuggestionsVisible(visible);
let candidates = [];
for (let i = startIndex; i < endIndex; ++i) {
candidates.push(lookupTable.get_candidate(i).get_text());
Main.keyboard.addSuggestion(lookupTable.get_candidate(i).get_text(), () => {
let index = i;
this._panelService.candidate_clicked(index, 1, 0);
});
}
this._candidateArea.setCandidates(indexes,
candidates,
cursorPos % pageSize,
lookupTable.is_cursor_visible());
this._candidateArea.setOrientation(lookupTable.get_orientation());
this._candidateArea.updateButtons(lookupTable.is_round(), page, nPages);
});
panelService.connect('show-lookup-table', () => {
Main.keyboard.setSuggestionsVisible(true);
this._candidateArea.show();
this._updateVisibility();
});
panelService.connect('hide-lookup-table', () => {
Main.keyboard.setSuggestionsVisible(false);
this._candidateArea.hide();
this._updateVisibility();
});
panelService.connect('focus-out', () => {
this.close(BoxPointer.PopupAnimation.NONE);
Main.keyboard.resetSuggestions();
});
}
_setDummyCursorGeometry(x, y, w, h) {
this._dummyCursor.set_position(Math.round(x), Math.round(y));
this._dummyCursor.set_size(Math.round(w), Math.round(h));
if (this.visible)
this.setPosition(this._dummyCursor, 0);
}
_updateVisibility() {
let isVisible = !Main.keyboard.visible &&
(this._preeditText.visible ||
this._auxText.visible ||
this._candidateArea.visible);
if (isVisible) {
this.setPosition(this._dummyCursor, 0);
this.open(BoxPointer.PopupAnimation.NONE);
// We shouldn't be above some components like the screenshot UI,
// so don't raise to the top.
// The on-screen keyboard is expected to be above any entries,
// so just above the keyboard gets us to the right layer.
const {keyboardBox} = Main.layoutManager;
this.get_parent().set_child_above_sibling(this, keyboardBox);
} else {
this.close(BoxPointer.PopupAnimation.NONE);
}
}
_setTextAttributes(clutterText, ibusAttrList) {
let attr;
for (let i = 0; (attr = ibusAttrList.get(i)); ++i) {
if (attr.get_attr_type() === IBus.AttrType.BACKGROUND)
clutterText.set_selection(attr.get_start_index(), attr.get_end_index());
}
}
});

1469
js/ui/iconGrid.js Normal file

File diff suppressed because it is too large Load diff

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