From 20d73be57db0c46368ee5e35cfdbb6b383b84aa8 Mon Sep 17 00:00:00 2001 From: Georges Basile Stavracas Neto Date: Tue, 19 Mar 2019 12:15:37 +0000 Subject: [PATCH] Introduce Automatic Updates component https://gitlab.gnome.org/GNOME/gnome-shell/merge_requests/466 --- data/gnome-shell-theme.gresource.xml | 3 + data/theme/automatic-updates-off-symbolic.svg | 1 + data/theme/automatic-updates-on-symbolic.svg | 1 + .../automatic-updates-scheduled-symbolic.svg | 1 + js/js-resources.gresource.xml | 2 + js/misc/updateManager.js | 338 ++++++++++++++++++ js/ui/components/updates.js | 135 +++++++ js/ui/sessionMode.js | 6 +- po/POTFILES.in | 1 + 9 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 data/theme/automatic-updates-off-symbolic.svg create mode 100644 data/theme/automatic-updates-on-symbolic.svg create mode 100644 data/theme/automatic-updates-scheduled-symbolic.svg create mode 100644 js/misc/updateManager.js create mode 100644 js/ui/components/updates.js diff --git a/data/gnome-shell-theme.gresource.xml b/data/gnome-shell-theme.gresource.xml index b77825414..171ca56f0 100644 --- a/data/gnome-shell-theme.gresource.xml +++ b/data/gnome-shell-theme.gresource.xml @@ -1,6 +1,9 @@ + automatic-updates-off-symbolic.svg + automatic-updates-on-symbolic.svg + automatic-updates-scheduled-symbolic.svg calendar-today.svg checkbox-focused.svg checkbox-off-focused.svg diff --git a/data/theme/automatic-updates-off-symbolic.svg b/data/theme/automatic-updates-off-symbolic.svg new file mode 100644 index 000000000..fb5f24446 --- /dev/null +++ b/data/theme/automatic-updates-off-symbolic.svg @@ -0,0 +1 @@ +EOS_symbolic-icons_v0.1auto-updates_OFF \ No newline at end of file diff --git a/data/theme/automatic-updates-on-symbolic.svg b/data/theme/automatic-updates-on-symbolic.svg new file mode 100644 index 000000000..8b23fe360 --- /dev/null +++ b/data/theme/automatic-updates-on-symbolic.svg @@ -0,0 +1 @@ +EOS_symbolic-icons_v0.1auto-updates_ON \ No newline at end of file diff --git a/data/theme/automatic-updates-scheduled-symbolic.svg b/data/theme/automatic-updates-scheduled-symbolic.svg new file mode 100644 index 000000000..c62eb2419 --- /dev/null +++ b/data/theme/automatic-updates-scheduled-symbolic.svg @@ -0,0 +1 @@ +EOS_symbolic-icons_v0.1update-scheduled_OUTLINE \ No newline at end of file diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml index 836d1c674..a969b6292 100644 --- a/js/js-resources.gresource.xml +++ b/js/js-resources.gresource.xml @@ -9,6 +9,7 @@ gdm/realmd.js gdm/util.js + misc/updateManager.js misc/config.js misc/extensionUtils.js misc/fileUtils.js @@ -116,6 +117,7 @@ ui/components/networkAgent.js ui/components/polkitAgent.js ui/components/telepathyClient.js + ui/components/updates.js ui/components/keyring.js ui/status/accessibility.js diff --git a/js/misc/updateManager.js b/js/misc/updateManager.js new file mode 100644 index 000000000..a057e9208 --- /dev/null +++ b/js/misc/updateManager.js @@ -0,0 +1,338 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// Copyright (C) 2019 Endless Mobile, Inc. +// +// This is a GNOME Shell component to wrap the interactions over +// D-Bus with the Mogwai system daemon. +// +// 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. + +const { Clutter, Gio, GLib, + GObject, Gtk, NM, Shell, St } = imports.gi; + +const NM_SETTING_AUTOMATIC_UPDATES_NOTIFICATION_TIME = "connection.automatic-updates-notification-time"; +const NM_SETTING_ALLOW_DOWNLOADS = 'connection.allow-downloads'; +const NM_SETTING_TARIFF_ENABLED = "connection.tariff-enabled"; + +const SchedulerInterface = '\ + \ + \ + \ + \ + \ +'; + +const SchedulerProxy = Gio.DBusProxy.makeProxyWrapper(SchedulerInterface); + +let _updateManager = null; + +function getUpdateManager() { + if (_updateManager == null) + _updateManager = new UpdateManager(); + return _updateManager; +} + +var State = { + UNKNOWN: 0, + DISCONNECTED: 1, + DISABLED: 2, + IDLE: 3, + SCHEDULED: 4, + DOWNLOADING: 5 +}; + +function stateToIconName(state) { + switch (state) { + case State.UNKNOWN: + case State.DISCONNECTED: + return null; + + case State.DISABLED: + return 'resource:///org/gnome/shell/theme/automatic-updates-off-symbolic.svg'; + + case State.IDLE: + case State.DOWNLOADING: + return 'resource:///org/gnome/shell/theme/automatic-updates-on-symbolic.svg'; + + case State.SCHEDULED: + return 'resource:///org/gnome/shell/theme/automatic-updates-scheduled-symbolic.svg'; + } + + return null; +} + +var UpdateManager = GObject.registerClass ({ + Properties: { + 'last-notification-time': GObject.ParamSpec.int('last-notification-time', + 'last-notification-time', + 'last-notification-time', + GObject.ParamFlags.READWRITE, + null), + 'icon': GObject.ParamSpec.object('icon', 'icon', 'icon', + GObject.ParamFlags.READABLE, + Gio.Icon.$gtype), + 'state': GObject.ParamSpec.uint('state', 'state', 'state', + GObject.ParamFlags.READABLE, + null), + }, +}, class UpdateManager extends GObject.Object { + _init() { + super._init(); + + this._activeConnection = null; + this._settingChangedSignalId = 0; + this._updateTimeoutId = 0; + + this._state = State.UNKNOWN; + + NM.Client.new_async(null, this._clientGot.bind(this)); + } + + _clientGot(obj, result) { + this._client = NM.Client.new_finish(result); + + this._client.connect('notify::primary-connection', this._sync.bind(this)); + this._client.connect('notify::state', this._sync.bind(this)); + + // Start retrieving the Mogwai proxy + this._proxy = new SchedulerProxy(Gio.DBus.system, + 'com.endlessm.MogwaiSchedule1', + '/com/endlessm/DownloadManager1', + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._proxy.connect('g-properties-changed', + this._sync.bind(this)); + this._updateStatus(); + }); + + this._sync(); + } + + _sync() { + if (!this._client || !this._proxy) + return; + + if (this._updateTimeoutId > 0) { + GLib.source_remove(this._updateTimeoutId); + this._updateTimeoutId = 0; + } + + // Intermediate states (connecting or disconnecting) must not trigger + // any kind of state change. + if (this._client.state == NM.State.CONNECTING || this._client.state == NM.State.DISCONNECTING) + return; + + // Use a timeout to avoid instantly throwing the notification at + // the user's face, and to avoid a series of unecessary updates + // that happen when NetworkManager is still figuring out details. + this._updateTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + 2, + () => { + this._updateStatus(); + this._updateTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._updateTimeoutId, '[update] updateStatus'); + } + + _updateStatus() { + // Update the current active connection. This will connect to the + // NM.SettingUser signal to sync every time someone updates the + // NM_SETTING_ALLOW_DOWNLOADS setting. + this._updateActiveConnection(); + + let state = this._getState(); + if (state != this._state) { + this._state = state; + this.notify('state'); + + this._updateIcon(); + } + } + + _updateActiveConnection() { + let currentActiveConnection = this._getActiveConnection(); + + if (this._activeConnection == currentActiveConnection) + return; + + // Disconnect from the previous active connection + if (this._settingChangedSignalId > 0) { + this._activeConnection.disconnect(this._settingChangedSignalId); + this._settingChangedSignalId = 0; + } + + this._activeConnection = currentActiveConnection; + + // Connect from the current active connection + if (currentActiveConnection) + this._settingChangedSignalId = currentActiveConnection.connect('changed', this._updateStatus.bind(this)); + } + + _ensureUserSetting(connection) { + let userSetting = connection.get_setting(NM.SettingUser.$gtype); + if (!userSetting) { + userSetting = new NM.SettingUser(); + connection.add_setting(userSetting); + } + return userSetting; + } + + _getState() { + if (!this._activeConnection) + return State.DISCONNECTED; + + let userSetting = this._ensureUserSetting(this._activeConnection); + + // We only return true when: + // * Automatic Updates are on + // * A schedule was set + // * Something is being downloaded + + let allowDownloadsValue = userSetting.get_data(NM_SETTING_ALLOW_DOWNLOADS); + if (allowDownloadsValue) { + let allowDownloads = (allowDownloadsValue === '1'); + + if (!allowDownloads) + return State.DISABLED; + } else { + // Guess the default value from the metered state. Only return + // if it's disabled - if it's not, we want to follow the regular + // code paths and fetch the correct state + let connectionSetting = this._activeConnection.get_setting_connection(); + + if (!connectionSetting) + return State.DISABLED; + + let metered = connectionSetting.get_metered(); + if (metered == NM.Metered.YES || metered == NM.Metered.GUESS_YES) + return State.DISABLED; + } + + // Without the proxy, we can't really know the state + if (!this._proxy) + return State.UNKNOWN; + + let scheduleSet = userSetting.get_data(NM_SETTING_TARIFF_ENABLED) === '1'; + if (!scheduleSet) + return State.IDLE; + + let downloading = this._proxy.ActiveEntryCount > 0; + if (downloading) + return State.DOWNLOADING; + + // At this point we're not downloading anything, but something + // might be queued + let downloadsQueued = this._proxy.EntryCount > 0; + if (downloadsQueued) + return State.SCHEDULED; + else + return State.IDLE; + } + + _getActiveConnection() { + let activeConnection = this._client.get_primary_connection(); + return activeConnection ? activeConnection.get_connection() : null; + } + + _updateIcon() { + let state = this._state; + let iconName = stateToIconName(state); + + if (iconName) { + let iconFile = Gio.File.new_for_uri(iconName); + this._icon = new Gio.FileIcon({ file: iconFile }); + } else { + this._icon = null; + } + + this.notify('icon'); + } + + get state() { + return this._state; + } + + get lastNotificationTime() { + let connection = this._getActiveConnection(); + if (!connection) + return -1; + + let userSetting = connection.get_setting(NM.SettingUser.$gtype); + if (!userSetting) + return -1; + + let time = userSetting.get_data(NM_SETTING_AUTOMATIC_UPDATES_NOTIFICATION_TIME); + return time ? parseInt(time) : -1; + } + + set lastNotificationTime(time) { + if (!this._activeConnection) + return; + + let userSetting = this._ensureUserSetting(this._activeConnection); + userSetting.set_data(NM_SETTING_AUTOMATIC_UPDATES_NOTIFICATION_TIME, + '%s'.format(time)); + + this._activeConnection.commit_changes(true, null); + } + + + get active() { + return this._active; + } + + set active(_active) { + if (this._active == _active) + return; + + this._active = _active; + this.notify('active'); + } + + get icon() { + return this._icon; + } + + toggleAutomaticUpdates() { + if (!this._activeConnection) + return; + + let userSetting = this._ensureUserSetting(this._activeConnection); + + let state = this._getState(); + let value; + + if (state == State.IDLE || + state == State.SCHEDULED || + state == State.DOWNLOADING) { + value = '0'; + } else { + value = '1'; + } + + userSetting.set_data(NM_SETTING_ALLOW_DOWNLOADS, value); + + this._activeConnection.commit_changes_async(true, null, (con, res, data) => { + this._activeConnection.commit_changes_finish(res); + this._updateStatus(); + }); + } +}); diff --git a/js/ui/components/updates.js b/js/ui/components/updates.js new file mode 100644 index 000000000..801dd8e80 --- /dev/null +++ b/js/ui/components/updates.js @@ -0,0 +1,135 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// Copyright (C) 2018 Endless Mobile, Inc. +// +// This is a GNOME Shell component to wrap the interactions over +// D-Bus with the Mogwai system daemon. +// +// 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. + +const { Gio, GLib, Shell } = imports.gi; + +const UpdateManager = imports.misc.updateManager; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +var UpdateComponent = class { + constructor() { + this._notification = null; + this._state = UpdateManager.State.UNKNOWN; + + this._manager = UpdateManager.getUpdateManager(); + this._manager.connect('notify::state', this._updateState.bind(this)); + + this._updateState(); + } + + enable() { + } + + disable() { + } + + _updateState() { + let newState = this._manager.state; + + if (this._state == newState) + return; + + this._updateNotification(newState); + this._state = newState; + } + + _updateNotification(newState) { + // Don't notify when starting up + if (this._manager.state == UpdateManager.State.UNKNOWN) + return; + + let alreadySentNotification = this._manager.lastNotificationTime != -1; + + let wasDisconnected = this._state == UpdateManager.State.DISCONNECTED; + let wasActive = this._state >= UpdateManager.State.IDLE; + let isActive = newState >= UpdateManager.State.IDLE; + + // The criteria to notify about the Automatic Updates setting is: + // 1. If the user was disconnected and connects to a new network; or + // 2. If the user was connected and connects to a network with different status; + if ((wasDisconnected && alreadySentNotification) || (!wasDisconnected && isActive == wasActive)) + return; + + if (this._notification) + this._notification.destroy(); + + if (newState == UpdateManager.State.DISCONNECTED) + return; + + let source = new MessageTray.SystemNotificationSource(); + Main.messageTray.add(source); + + // Figure out the title, subtitle and icon + let title, subtitle, iconFile; + + if (isActive) { + title = _("Automatic updates on"); + subtitle = _("Your connection has unlimited data so automatic updates have been turned on."); + iconFile = UpdateManager.stateToIconName(UpdateManager.State.IDLE); + } else { + title = _("Automatic updates are turned off to save your data"); + subtitle = _("You will need to choose which updates to apply when on this connection."); + iconFile = UpdateManager.stateToIconName(UpdateManager.State.DISABLED); + } + + let gicon = new Gio.FileIcon({ file: Gio.File.new_for_uri(iconFile) }); + + // Create the notification. + // The first time we notify the user for a given connection, + // we set the urgency to critical so that we make sure the + // user understands how we may be changing their settings. + // On subsequent notifications for the given connection, + // for instance if the user regularly switches between + // metered and unmetered connections, we set the urgency + // to normal so as not to be too obtrusive. + this._notification = new MessageTray.Notification(source, title, subtitle, { gicon: gicon }); + this._notification.setUrgency(alreadySentNotification ? + MessageTray.Urgency.NORMAL : MessageTray.Urgency.CRITICAL); + this._notification.setTransient(false); + + this._notification.addAction(_("Close"), () => { + this._notification.destroy(); + }); + + this._notification.addAction(_("Change Settings…"), () => { + // FIXME: this requires the Automatic Updates panel in GNOME + // Settings. Going with the Network panel for now… + let app = Shell.AppSystem.get_default().lookup_app('gnome-network-panel.desktop'); + Main.overview.hide(); + app.activate(); + }); + + source.notify(this._notification); + + this._notification.connect('destroy', () => { + this._notification = null; + }); + + // Now that we first detected this connection, mark it as such + this._manager.lastNotificationTime = GLib.get_real_time(); + } +}; + +var Component = UpdateComponent; diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js index 25aa75a3d..3783f79e8 100644 --- a/js/ui/sessionMode.js +++ b/js/ui/sessionMode.js @@ -92,9 +92,11 @@ const _modes = { unlockDialog: imports.ui.unlockDialog.UnlockDialog, components: Config.HAVE_NETWORKMANAGER ? ['networkAgent', 'polkitAgent', 'telepathyClient', - 'keyring', 'autorunManager', 'automountManager'] : + 'keyring', 'autorunManager', 'automountManager', + 'updates'] : ['polkitAgent', 'telepathyClient', - 'keyring', 'autorunManager', 'automountManager'], + 'keyring', 'autorunManager', 'automountManager', + 'updates'], panel: { left: ['activities', 'appMenu'], diff --git a/po/POTFILES.in b/po/POTFILES.in index 43ea408ac..7bcded50b 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -52,6 +52,7 @@ js/ui/search.js js/ui/shellEntry.js js/ui/shellMountOperation.js js/ui/status/accessibility.js +js/ui/status/automaticUpdates.js js/ui/status/bluetooth.js js/ui/status/brightness.js js/ui/status/keyboard.js