From f7ded3e50986cd795c732c625b6b8ec7ad5f0edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Mon, 18 Dec 2023 20:20:26 +0100 Subject: [PATCH] extensions-app: Split out ExtensionManager The extension handling is currently intertwined with the UI. Splitting it out provides a clearer separation, and will allow us to switch to a model-based UI later. Part-of: --- po/POTFILES.in | 1 + .../extensions-app/js/extensionManager.js | 295 ++++++++++++++++++ subprojects/extensions-app/js/extensionRow.js | 121 ++----- .../extensions-app/js/extensionsWindow.js | 144 ++++----- subprojects/extensions-app/js/main.js | 28 +- .../org.gnome.Extensions.src.gresource.xml.in | 1 + 6 files changed, 384 insertions(+), 206 deletions(-) create mode 100644 subprojects/extensions-app/js/extensionManager.js diff --git a/po/POTFILES.in b/po/POTFILES.in index f7c05eefc..35cb77c69 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -92,6 +92,7 @@ src/shell-util.c src/st/st-icon-theme.c subprojects/extensions-app/data/metainfo/org.gnome.Extensions.metainfo.xml.in subprojects/extensions-app/data/org.gnome.Extensions.desktop.in.in +subprojects/extensions-app/js/extensionManager.js subprojects/extensions-app/js/extensionRow.js subprojects/extensions-app/js/extensionsWindow.js subprojects/extensions-app/data/ui/extension-row.ui diff --git a/subprojects/extensions-app/js/extensionManager.js b/subprojects/extensions-app/js/extensionManager.js new file mode 100644 index 000000000..25f02618e --- /dev/null +++ b/subprojects/extensions-app/js/extensionManager.js @@ -0,0 +1,295 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import { + ExtensionState, ExtensionType, deserializeExtension +} from './misc/extensionUtils.js'; + +const GnomeShellIface = loadInterfaceXML('org.gnome.Shell.Extensions'); +const GnomeShellProxy = Gio.DBusProxy.makeProxyWrapper(GnomeShellIface); + +let shellVersion; + +function loadInterfaceXML(iface) { + const uri = `resource:///org/gnome/Extensions/dbus-interfaces/${iface}.xml`; + const f = Gio.File.new_for_uri(uri); + + try { + let [ok_, bytes] = f.load_contents(null); + return new TextDecoder().decode(bytes); + } catch (e) { + console.error(`Failed to load D-Bus interface ${iface}`); + } + + return null; +} + +class Extension { + constructor(variant) { + this.update(variant); + } + + update(variant) { + const deserialized = deserializeExtension(variant); + + const { + uuid, type, state, error, hasPrefs, hasUpdate, canChange, metadata, + } = deserialized; + + if (!this._uuid) + this._uuid = uuid; + + if (this._uuid !== uuid) + throw new Error(`Invalid update of extension ${this._uuid} with data from ${uuid}`); + + const {name} = metadata; + this._name = name; + [this._keywords] = GLib.str_tokenize_and_fold(name, null); + + const [desc] = metadata.description.split('\n'); + this._description = desc; + + this._type = type; + this._errorDetail = error; + this._state = state; + + const creator = metadata.creator ?? ''; + this._creator = creator; + + const url = metadata.url ?? ''; + this._url = url; + + const version = String( + metadata['version-name'] || metadata['version'] || ''); + this._version = version; + + this._hasPrefs = hasPrefs; + this._hasUpdate = hasUpdate; + this._canChange = canChange; + } + + get uuid() { + return this._uuid; + } + + get name() { + return this._name; + } + + get description() { + return this._description; + } + + get state() { + return this._state; + } + + get creator() { + return this._creator; + } + + get url() { + return this._url; + } + + get version() { + return this._version; + } + + get keywords() { + return this._keywords; + } + + get error() { + if (!this.hasError) + return ''; + + if (this.state === ExtensionState.OUT_OF_DATE) { + return this.version !== '' + ? _('The installed version of this extension (%s) is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(this.version, shellVersion) + : _('The installed version of this extension is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(shellVersion); + } + + const message = [ + _('An error has occurred in this extension. This could cause issues elsewhere in the system. It is recommended to turn the extension off until the error is resolved.'), + ]; + + if (this._errorDetail) { + message.push( + // translators: Details for an extension error + _('Error details:'), this._errorDetail); + } + + return message.join('\n\n'); + } + + get hasError() { + return this.state === ExtensionState.OUT_OF_DATE || + this.state === ExtensionState.ERROR; + } + + get hasPrefs() { + return this._hasPrefs; + } + + get hasUpdate() { + return this._hasUpdate; + } + + get hasVersion() { + return this._version !== ''; + } + + get canChange() { + return this._canChange; + } + + get isUser() { + return this._type === ExtensionType.PER_USER; + } +} + +export const ExtensionManager = GObject.registerClass({ + Properties: { + 'user-extensions-enabled': GObject.ParamSpec.boolean( + 'user-extensions-enabled', null, null, + GObject.ParamFlags.READWRITE, + true), + 'n-updates': GObject.ParamSpec.int( + 'n-updates', null, null, + GObject.ParamFlags.READABLE, + 0, 999, 0), + 'failed': GObject.ParamSpec.boolean( + 'failed', null, null, + GObject.ParamFlags.READABLE, + false), + }, + Signals: { + 'extension-added': {param_types: [GObject.TYPE_JSOBJECT]}, + 'extension-removed': {param_types: [GObject.TYPE_JSOBJECT]}, + 'extension-changed': {param_types: [GObject.TYPE_JSOBJECT], flags: GObject.SignalFlags.DETAILED}, + 'extensions-loaded': {}, + }, +}, class ExtensionManager extends GObject.Object { + constructor() { + super(); + + this._extensions = new Map(); + + this._proxyReady = false; + this._shellProxy = new GnomeShellProxy(Gio.DBus.session, + 'org.gnome.Shell.Extensions', '/org/gnome/Shell/Extensions', + () => { + this._proxyReady = true; + shellVersion = this._shellProxy.ShellVersion; + + this._shellProxy.connect('notify::g-name-owner', + () => this.notify('failed')); + this.notify('failed'); + }); + + this._shellProxy.connect('g-properties-changed', (proxy, properties) => { + const enabledChanged = !!properties.lookup_value('UserExtensionsEnabled', null); + if (enabledChanged) + this.notify('user-extensions-enabled'); + }); + this._shellProxy.connectSignal( + 'ExtensionStateChanged', this._onExtensionStateChanged.bind(this)); + + this._loadExtensions().catch(console.error); + } + + get userExtensionsEnabled() { + return this._shellProxy.UserExtensionsEnabled ?? false; + } + + set userExtensionsEnabled(enabled) { + this._shellProxy.UserExtensionsEnabled = enabled; + } + + get nUpdates() { + let nUpdates = 0; + for (const ext of this._extensions.values()) { + if (ext.isUser && ext.hasUpdate) + nUpdates++; + } + return nUpdates; + } + + get failed() { + return this._proxyReady && this._shellProxy.gNameOwner === null; + } + + enableExtension(uuid) { + this._shellProxy.EnableExtensionAsync(uuid).catch(console.error); + } + + disableExtension(uuid) { + this._shellProxy.DisableExtensionAsync(uuid).catch(console.error); + } + + uninstallExtension(uuid) { + this._shellProxy.UninstallExtensionAsync(uuid).catch(console.error); + } + + openExtensionPrefs(uuid, parentHandle) { + this._shellProxy.OpenExtensionPrefsAsync(uuid, + parentHandle, + {modal: new GLib.Variant('b', true)}).catch(console.error); + } + + checkForUpdates() { + this._shellProxy.CheckForUpdatesAsync().catch(console.error); + } + + _addExtension(extension) { + const {uuid} = extension; + if (this._extensions.has(uuid)) + return; + + this._extensions.set(uuid, extension); + this.emit('extension-added', extension); + } + + _removeExtension(extension) { + const {uuid} = extension; + if (this._extensions.delete(uuid)) + this.emit('extension-removed', extension); + } + + async _loadExtensions() { + const [extensionsMap] = await this._shellProxy.ListExtensionsAsync(); + + for (let uuid in extensionsMap) { + const extension = new Extension(extensionsMap[uuid]); + this._addExtension(extension); + } + this.emit('extensions-loaded'); + } + + _onExtensionStateChanged(p, sender, [uuid, newState]) { + const extension = this._extensions.get(uuid); + + if (extension) + extension.update(newState); + + if (!extension) + this._addExtension(new Extension(newState)); + else if (extension.state === ExtensionState.UNINSTALLED) + this._removeExtension(extension); + else + this.emit(`extension-changed::${uuid}`, extension); + + if (this._updatesCheckId) + return; + + this._updatesCheckId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, 1, () => { + this.notify('n-updates'); + + delete this._updatesCheckId; + return GLib.SOURCE_REMOVE; + }); + } +}); diff --git a/subprojects/extensions-app/js/extensionRow.js b/subprojects/extensions-app/js/extensionRow.js index 23bed79d2..d2bb2c498 100644 --- a/subprojects/extensions-app/js/extensionRow.js +++ b/subprojects/extensions-app/js/extensionRow.js @@ -3,7 +3,7 @@ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; -import {ExtensionState, ExtensionType, deserializeExtension} from './misc/extensionUtils.js'; +import {ExtensionState} from './misc/extensionUtils.js'; export const ExtensionRow = GObject.registerClass({ GTypeName: 'ExtensionRow', @@ -25,40 +25,38 @@ export const ExtensionRow = GObject.registerClass({ this._app = Gio.Application.get_default(); this._extension = extension; - [this._keywords] = GLib.str_tokenize_and_fold(this.name, null); - this._actionGroup = new Gio.SimpleActionGroup(); this.insert_action_group('row', this._actionGroup); let action; action = new Gio.SimpleAction({ name: 'show-prefs', - enabled: this.hasPrefs, + enabled: extension.hasPrefs, }); action.connect('activate', () => { this._detailsPopover.popdown(); - this.get_root().openPrefs(this.uuid); + this.get_root().openPrefs(extension); }); this._actionGroup.add_action(action); action = new Gio.SimpleAction({ name: 'show-url', - enabled: this.url !== '', + enabled: extension.url !== '', }); action.connect('activate', () => { this._detailsPopover.popdown(); Gio.AppInfo.launch_default_for_uri( - this.url, this.get_display().get_app_launch_context()); + extension.url, this.get_display().get_app_launch_context()); }); this._actionGroup.add_action(action); action = new Gio.SimpleAction({ name: 'uninstall', - enabled: this.type === ExtensionType.PER_USER, + enabled: extension.isUser, }); action.connect('activate', () => { this._detailsPopover.popdown(); - this.get_root().uninstall(this.uuid); + this.get_root().uninstall(extension); }); this._actionGroup.add_action(action); @@ -70,97 +68,28 @@ export const ExtensionRow = GObject.registerClass({ const state = action.get_state(); action.change_state(new GLib.Variant('b', !state.get_boolean())); }); - action.connect('change-state', (a, state) => { + const {uuid} = this._extension; if (state.get_boolean()) - this._app.shellProxy.EnableExtensionAsync(this.uuid).catch(console.error); + this._app.extensionManager.enableExtension(uuid); else - this._app.shellProxy.DisableExtensionAsync(this.uuid).catch(console.error); + this._app.extensionManager.disableExtension(uuid); }); this._actionGroup.add_action(action); - this.title = this.name; + this.title = extension.name; - const desc = this._extension.metadata.description.split('\n')[0]; - this._descriptionLabel.label = desc; + this._descriptionLabel.label = extension.description; this.connect('destroy', this._onDestroy.bind(this)); - this._extensionStateChangedId = this._app.shellProxy.connectSignal( - 'ExtensionStateChanged', (p, sender, [uuid, newState]) => { - if (this.uuid !== uuid) - return; - - this._extension = deserializeExtension(newState); - this._updateState(); - }); + this._extensionStateChangedId = this._app.extensionManager.connect( + `extension-changed::${extension.uuid}`, () => this._updateState()); this._updateState(); } - get uuid() { - return this._extension.uuid; - } - - get name() { - return this._extension.metadata.name; - } - - get hasPrefs() { - return this._extension.hasPrefs; - } - - get hasUpdate() { - return this._extension.hasUpdate || false; - } - - get hasError() { - const {state} = this._extension; - return state === ExtensionState.OUT_OF_DATE || - state === ExtensionState.ERROR; - } - - get type() { - return this._extension.type; - } - - get creator() { - return this._extension.metadata.creator || ''; - } - - get url() { - return this._extension.metadata.url || ''; - } - - get version() { - return this._extension.metadata['version-name'] || this._extension.metadata.version || ''; - } - - get error() { - if (!this.hasError) - return ''; - - if (this._extension.state === ExtensionState.OUT_OF_DATE) { - const {ShellVersion: shellVersion} = this._app.shellProxy; - return this.version !== '' - ? _('The installed version of this extension (%s) is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(this.version, shellVersion) - : _('The installed version of this extension is incompatible with the current version of GNOME (%s). The extension has been disabled.').format(shellVersion); - } - - const message = [ - _('An error has occurred in this extension. This could cause issues elsewhere in the system. It is recommended to turn the extension off until the error is resolved.'), - ]; - - if (this._extension.error) { - message.push( - // translators: Details for an extension error - _('Error details:'), this._extension.error); - } - - return message.join('\n\n'); - } - - get keywords() { - return this._keywords; + get extension() { + return this._extension; } _updateState() { @@ -173,21 +102,17 @@ export const ExtensionRow = GObject.registerClass({ if (!action.enabled) this._switch.active = state; - this._updatesButton.visible = this.hasUpdate; - this._errorButton.visible = this.hasError; - this._errorLabel.label = this.error; + this._updatesButton.visible = this._extension.hasUpdate; + this._errorButton.visible = this._extension.hasError; + this._errorLabel.label = this._extension.error; - this._versionLabel.label = _('Version %s').format(this.version.toString()); - this._versionLabel.visible = this.version !== ''; + this._versionLabel.label = _('Version %s').format(this._extension.version); + this._versionLabel.visible = this._extension.version !== ''; } _onDestroy() { - if (!this._app.shellProxy) - return; - if (this._extensionStateChangedId) - this._app.shellProxy.disconnectSignal(this._extensionStateChangedId); - this._extensionStateChangedId = 0; + this._app.extensionManager.disconnect(this._extensionStateChangedId); + delete this._extensionStateChangedId; } }); - diff --git a/subprojects/extensions-app/js/extensionsWindow.js b/subprojects/extensions-app/js/extensionsWindow.js index 66671a68f..4af3630ea 100644 --- a/subprojects/extensions-app/js/extensionsWindow.js +++ b/subprojects/extensions-app/js/extensionsWindow.js @@ -10,7 +10,6 @@ import * as Gettext from 'gettext'; import * as Config from './misc/config.js'; import {ExtensionRow} from './extensionRow.js'; -import {ExtensionState, ExtensionType, deserializeExtension} from './misc/extensionUtils.js'; Gio._promisify(Gio.DBusConnection.prototype, 'call'); Gio._promisify(Shew.WindowExporter.prototype, 'export'); @@ -36,8 +35,6 @@ export const ExtensionsWindow = GObject.registerClass({ if (Config.PROFILE === 'development') this.add_css_class('devel'); - this._updatesCheckId = 0; - this._exporter = new Shew.WindowExporter({window: this}); this._exportedHandle = ''; @@ -52,7 +49,8 @@ export const ExtensionsWindow = GObject.registerClass({ name: 'user-extensions-enabled', state: 'false', change_state: (a, state) => { - this._shellProxy.UserExtensionsEnabled = state.get_boolean(); + const {extensionManager} = this.application; + extensionManager.userExtensionsEnabled = state.get_boolean(); }, }]); @@ -90,27 +88,38 @@ export const ExtensionsWindow = GObject.registerClass({ })); this._systemList.connect('row-activated', (_list, row) => row.activate()); - this._shellProxy.connectSignal('ExtensionStateChanged', - this._onExtensionStateChanged.bind(this)); - - this._shellProxy.connect('g-properties-changed', + const {extensionManager} = this.application; + extensionManager.connect('notify::failed', + () => this._syncVisiblePage()); + extensionManager.connect('notify::n-updates', + () => this._checkUpdates()); + extensionManager.connect('notify::user-extensions-enabled', this._onUserExtensionsEnabledChanged.bind(this)); this._onUserExtensionsEnabledChanged(); - this._scanExtensions(); + extensionManager.connect('extension-added', + (mgr, extension) => this._addExtensionRow(extension)); + extensionManager.connect('extension-removed', + (mgr, extension) => this._removeExtensionRow(extension)); + extensionManager.connect('extension-changed', + (mgr, extension) => { + const row = this._findExtensionRow(extension); + const isUser = row?.get_parent() === this._userList; + if (extension.isUser !== isUser) { + this._removeExtensionRow(extension); + this._addExtensionRow(extension); + } + }); + + extensionManager.connect('extensions-loaded', + () => this._extensionsLoaded()); } - get _shellProxy() { - return this.application.shellProxy; - } - - uninstall(uuid) { - const row = this._findExtensionRow(uuid); - + uninstall(extension) { const dialog = new Gtk.MessageDialog({ transient_for: this, modal: true, - text: _('Remove ā€œ%sā€?').format(row.name), + text: _('Remove ā€œ%sā€?').format(extension.name), secondary_text: _('If you remove the extension, you need to return to download it if you want to enable it again'), }); @@ -119,14 +128,16 @@ export const ExtensionsWindow = GObject.registerClass({ .get_style_context().add_class('destructive-action'); dialog.connect('response', (dlg, response) => { + const {extensionManager} = this.application; + if (response === Gtk.ResponseType.ACCEPT) - this._shellProxy.UninstallExtensionAsync(uuid).catch(console.error); + extensionManager.uninstallExtension(extension.uuid); dialog.destroy(); }); dialog.present(); } - async openPrefs(uuid) { + async openPrefs(extension) { if (!this._exportedHandle) { try { this._exportedHandle = await this._exporter.export(); @@ -135,9 +146,8 @@ export const ExtensionsWindow = GObject.registerClass({ } } - this._shellProxy.OpenExtensionPrefsAsync(uuid, - this._exportedHandle, - {modal: new GLib.Variant('b', true)}).catch(console.error); + const {extensionManager} = this.application; + extensionManager.openExtensionPrefs(extension.uuid, this._exportedHandle); } _showAbout() { @@ -180,104 +190,68 @@ export const ExtensionsWindow = GObject.registerClass({ } _sortList(row1, row2) { - return row1.name.localeCompare(row2.name); + const {name: name1} = row1.extension; + const {name: name2} = row2.extension; + return name1.localeCompare(name2); } _filterList(row) { + const {keywords} = row.extension; return this._searchTerms.every( - t => row.keywords.some(k => k.startsWith(t))); + t => keywords.some(k => k.startsWith(t))); } - _findExtensionRow(uuid) { + _findExtensionRow(extension) { return [ ...this._userList, ...this._systemList, - ].find(c => c.uuid === uuid); + ].find(c => c.extension === extension); } _onUserExtensionsEnabledChanged() { + const {userExtensionsEnabled} = this.application.extensionManager; const action = this.lookup_action('user-extensions-enabled'); - action.set_state( - new GLib.Variant('b', this._shellProxy.UserExtensionsEnabled)); - } - - _onExtensionStateChanged(proxy, senderName, [uuid, newState]) { - const extension = deserializeExtension(newState); - let row = this._findExtensionRow(uuid); - - this._queueUpdatesCheck(); - - // the extension's type changed; remove the corresponding row - // and reset the variable to null so that we create a new row - // below and add it to the appropriate list - if (row && row.type !== extension.type) { - row.get_parent().remove(row); - row = null; - } - - if (row) { - if (extension.state === ExtensionState.UNINSTALLED) - row.get_parent().remove(row); - } else { - this._addExtensionRow(extension); - } - - this._syncListVisibility(); - } - - async _scanExtensions() { - try { - const [extensionsMap] = await this._shellProxy.ListExtensionsAsync(); - - for (let uuid in extensionsMap) { - const extension = deserializeExtension(extensionsMap[uuid]); - this._addExtensionRow(extension); - } - this._extensionsLoaded(); - } catch (e) { - if (e instanceof Gio.DBusError) { - console.log(`Failed to connect to shell proxy: ${e}`); - this._mainStack.visible_child_name = 'noshell'; - } else { - throw e; - } - } + action.set_state(new GLib.Variant('b', userExtensionsEnabled)); } _addExtensionRow(extension) { const row = new ExtensionRow(extension); - if (row.type === ExtensionType.PER_USER) + if (extension.isUser) this._userList.append(row); else this._systemList.append(row); + + this._syncListVisibility(); } - _queueUpdatesCheck() { - if (this._updatesCheckId) - return; - - this._updatesCheckId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, 1, () => { - this._checkUpdates(); - - this._updatesCheckId = 0; - return GLib.SOURCE_REMOVE; - }); + _removeExtensionRow(extension) { + const row = this._findExtensionRow(extension); + if (row) + row.get_parent().remove(row); + this._syncListVisibility(); } _syncListVisibility() { this._userGroup.visible = [...this._userList].length > 1; this._systemGroup.visible = [...this._systemList].length > 1; - if (this._userGroup.visible || this._systemGroup.visible) + this._syncVisiblePage(); + } + + _syncVisiblePage() { + const {extensionManager} = this.application; + + if (extensionManager.failed) + this._mainStack.visible_child_name = 'noshell'; + else if (this._userGroup.visible || this._systemGroup.visible) this._mainStack.visible_child_name = 'main'; else this._mainStack.visible_child_name = 'placeholder'; } _checkUpdates() { - const nUpdates = [...this._userList].filter(c => c.hasUpdate).length; + const {nUpdates} = this.application.extensionManager; this._updatesBanner.title = Gettext.ngettext( '%d extension will be updated on next login.', diff --git a/subprojects/extensions-app/js/main.js b/subprojects/extensions-app/js/main.js index 47f0d061a..7b3af1cc7 100644 --- a/subprojects/extensions-app/js/main.js +++ b/subprojects/extensions-app/js/main.js @@ -1,6 +1,5 @@ import Adw from 'gi://Adw?version=1'; import GLib from 'gi://GLib'; -import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import {setConsoleLogDomain} from 'console'; @@ -8,25 +7,9 @@ const Package = imports.package; Package.initFormat(); +import {ExtensionManager} from './extensionManager.js'; import {ExtensionsWindow} from './extensionsWindow.js'; -const GnomeShellIface = loadInterfaceXML('org.gnome.Shell.Extensions'); -const GnomeShellProxy = Gio.DBusProxy.makeProxyWrapper(GnomeShellIface); - -function loadInterfaceXML(iface) { - const uri = `resource:///org/gnome/Extensions/dbus-interfaces/${iface}.xml`; - const f = Gio.File.new_for_uri(uri); - - try { - let [ok_, bytes] = f.load_contents(null); - return new TextDecoder().decode(bytes); - } catch (e) { - console.error(`Failed to load D-Bus interface ${iface}`); - } - - return null; -} - var Application = GObject.registerClass( class Application extends Adw.Application { _init() { @@ -36,12 +19,12 @@ class Application extends Adw.Application { this.connect('window-removed', (a, window) => window.run_dispose()); } - get shellProxy() { - return this._shellProxy; + get extensionManager() { + return this._extensionManager; } vfunc_activate() { - this._shellProxy.CheckForUpdatesAsync().catch(console.error); + this._extensionManager.checkForUpdates(); this._window.present(); } @@ -56,8 +39,7 @@ class Application extends Adw.Application { this.set_accels_for_action('app.quit', ['q']); - this._shellProxy = new GnomeShellProxy(Gio.DBus.session, - 'org.gnome.Shell.Extensions', '/org/gnome/Shell/Extensions'); + this._extensionManager = new ExtensionManager(); this._window = new ExtensionsWindow({application: this}); } diff --git a/subprojects/extensions-app/js/org.gnome.Extensions.src.gresource.xml.in b/subprojects/extensions-app/js/org.gnome.Extensions.src.gresource.xml.in index eb7759aec..554f3c77d 100644 --- a/subprojects/extensions-app/js/org.gnome.Extensions.src.gresource.xml.in +++ b/subprojects/extensions-app/js/org.gnome.Extensions.src.gresource.xml.in @@ -1,6 +1,7 @@ + extensionManager.js extensionRow.js extensionsWindow.js main.js