gnome-shell/js/ui/extensionDownloader.js
Florian Müllner c170d6c956 extensionSystem: Support locking down extension installation
Currently extensions can only be locked down completely by
restricting the `enabled-extensions` key via dconf.

This is too restrictive for environments that want to allow users
to customize their system with extensions, while still limiting
the set of possible extensions.

To fill that gap, add a new `allow-extension-installation` setting,
which restricts extensions to system extensions when disabled.

As the setting is mainly intended for locking down by system
administrators, there is no attempt to load/unload extensions
on settings changes.

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3180>
2024-02-14 16:20:59 +00:00

350 lines
11 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
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(global.get_current_time());
}
/**
* @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 (e) {
// 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();
}