// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- const Clutter = imports.gi.Clutter; const GLib = imports.gi.GLib; const Gio = imports.gi.Gio; const Lang = imports.lang; const Main = imports.ui.main; const PanelMenu = imports.ui.panelMenu; const PopupMenu = imports.ui.popupMenu; const ModalDialog = imports.ui.modalDialog; const Shell = imports.gi.Shell; const Signals = imports.signals; const St = imports.gi.St; const LOCATION_SCHEMA = 'org.gnome.system.location'; const MAX_ACCURACY_LEVEL = 'max-accuracy-level'; const ENABLED = 'enabled'; const APP_PERMISSIONS_TABLE = 'gnome'; const APP_PERMISSIONS_ID = 'geolocation'; const GeoclueAccuracyLevel = { NONE: 0, COUNTRY: 1, CITY: 4, NEIGHBORHOOD: 5, STREET: 6, EXACT: 8 }; function accuracyLevelToString(accuracyLevel) { for (let key in GeoclueAccuracyLevel) { if (GeoclueAccuracyLevel[key] == accuracyLevel) return key; } return 'NONE'; } var GeoclueIface = ' \ \ \ \ \ \ \ \ '; const GeoclueManager = Gio.DBusProxy.makeProxyWrapper(GeoclueIface); var AgentIface = ' \ \ \ \ \ \ \ \ \ \ '; var XdgAppIface = ' \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ '; const PermissionStore = Gio.DBusProxy.makeProxyWrapper(XdgAppIface); const Indicator = new Lang.Class({ Name: 'LocationIndicator', Extends: PanelMenu.SystemIndicator, _init: function() { this.parent(); this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA }); this._settings.connect('changed::' + ENABLED, Lang.bind(this, this._onMaxAccuracyLevelChanged)); this._settings.connect('changed::' + MAX_ACCURACY_LEVEL, Lang.bind(this, this._onMaxAccuracyLevelChanged)); this._indicator = this._addIndicator(); this._indicator.icon_name = 'find-location-symbolic'; this._item = new PopupMenu.PopupSubMenuMenuItem('', true); this._item.icon.icon_name = 'find-location-symbolic'; this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this); this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent'); this._item.label.text = _("Location Enabled"); this._onOffAction = this._item.menu.addAction(_("Disable"), Lang.bind(this, this._onOnOffAction)); this._item.menu.addSettingsAction(_("Privacy Settings"), 'gnome-privacy-panel.desktop'); this.menu.addMenuItem(this._item); this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM, 'org.freedesktop.GeoClue2', 0, Lang.bind(this, this._connectToGeoclue), Lang.bind(this, this._onGeoclueVanished)); Main.sessionMode.connect('updated', Lang.bind(this, this._onSessionUpdated)); this._onSessionUpdated(); this._onMaxAccuracyLevelChanged(); this._connectToGeoclue(); this._connectToPermissionStore(); }, get MaxAccuracyLevel() { return this._getMaxAccuracyLevel(); }, AuthorizeAppAsync: function(params, invocation) { let [desktopId, reqAccuracyLevel] = params; let authorizer = new AppAuthorizer(desktopId, reqAccuracyLevel, this._permStoreProxy, this._getMaxAccuracyLevel()); authorizer.authorize(Lang.bind(this, function(accuracyLevel) { let ret = (accuracyLevel != GeoclueAccuracyLevel.NONE); invocation.return_value(GLib.Variant.new('(bu)', [ret, accuracyLevel])); })); }, _syncIndicator: function() { if (this._managerProxy == null) { this._indicator.visible = false; this._item.actor.visible = false; return; } this._indicator.visible = this._managerProxy.InUse; this._item.actor.visible = this._indicator.visible; this._updateMenuLabels(); }, _connectToGeoclue: function() { if (this._managerProxy != null || this._connecting) return false; this._connecting = true; new GeoclueManager(Gio.DBus.system, 'org.freedesktop.GeoClue2', '/org/freedesktop/GeoClue2/Manager', Lang.bind(this, this._onManagerProxyReady)); return true; }, _onManagerProxyReady: function(proxy, error) { if (error != null) { log(error.message); this._connecting = false; return; } this._managerProxy = proxy; this._propertiesChangedId = this._managerProxy.connect('g-properties-changed', Lang.bind(this, this._onGeocluePropsChanged)); this._syncIndicator(); this._managerProxy.AddAgentRemote('gnome-shell', Lang.bind(this, this._onAgentRegistered)); }, _onAgentRegistered: function(result, error) { this._connecting = false; this._notifyMaxAccuracyLevel(); if (error != null) log(error.message); }, _onGeoclueVanished: function() { if (this._propertiesChangedId) { this._managerProxy.disconnect(this._propertiesChangedId); this._propertiesChangedId = 0; } this._managerProxy = null; this._syncIndicator(); }, _onOnOffAction: function() { let enabled = this._settings.get_boolean(ENABLED); this._settings.set_boolean(ENABLED, !enabled); }, _onSessionUpdated: function() { let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; this.menu.setSensitive(sensitive); }, _updateMenuLabels: function() { if (this._settings.get_boolean(ENABLED)) { this._item.label.text = this._indicator.visible ? _("Location In Use") : _("Location Enabled"); this._onOffAction.label.text = _("Disable"); } else { this._item.label.text = _("Location Disabled"); this._onOffAction.label.text = _("Enable"); } }, _onMaxAccuracyLevelChanged: function() { this._updateMenuLabels(); // Gotta ensure geoclue is up and we are registered as agent to it // before we emit the notify for this property change. if (!this._connectToGeoclue()) this._notifyMaxAccuracyLevel(); }, _getMaxAccuracyLevel: function() { if (this._settings.get_boolean(ENABLED)) { let level = this._settings.get_string(MAX_ACCURACY_LEVEL); return GeoclueAccuracyLevel[level.toUpperCase()] || GeoclueAccuracyLevel.NONE; } else { return GeoclueAccuracyLevel.NONE; } }, _notifyMaxAccuracyLevel: function() { let variant = new GLib.Variant('u', this._getMaxAccuracyLevel()); this._agent.emit_property_changed('MaxAccuracyLevel', variant); }, _onGeocluePropsChanged: function(proxy, properties) { let unpacked = properties.deep_unpack(); if ("InUse" in unpacked) this._syncIndicator(); }, _connectToPermissionStore: function() { this._permStoreProxy = null; new PermissionStore(Gio.DBus.session, 'org.freedesktop.XdgApp', '/org/freedesktop/XdgApp/PermissionStore', Lang.bind(this, this._onPermStoreProxyReady)); }, _onPermStoreProxyReady: function(proxy, error) { if (error != null) { log(error.message); return; } this._permStoreProxy = proxy; }, }); function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } const AppAuthorizer = new Lang.Class({ Name: 'LocationAppAuthorizer', _init: function(desktopId, reqAccuracyLevel, permStoreProxy, maxAccuracyLevel) { this.desktopId = desktopId; this.reqAccuracyLevel = reqAccuracyLevel; this._permStoreProxy = permStoreProxy; this._maxAccuracyLevel = maxAccuracyLevel; this._accuracyLevel = GeoclueAccuracyLevel.NONE; this._timesAllowed = 0; }, authorize: function(onAuthDone) { this._onAuthDone = onAuthDone; let appSystem = Shell.AppSystem.get_default(); this._app = appSystem.lookup_app(this.desktopId + ".desktop"); if (this._app == null || this._permStoreProxy == null) { this._completeAuth(); return; } this._permStoreProxy.LookupRemote(APP_PERMISSIONS_TABLE, APP_PERMISSIONS_ID, Lang.bind(this, this._onPermLookupDone)); }, _onPermLookupDone: function(result, error) { if (error != null) { if (error.domain == Gio.DBusError) { // Likely no xdg-app installed, just authorize the app this._accuracyLevel = this.reqAccuracyLevel; this._permStoreProxy = null; this._completeAuth(); } else { // Currently xdg-app throws an error if we lookup for // unknown ID (which would be the case first time this code // runs) so we continue with user authorization as normal // and ID is added to the store if user says "yes". log(error.message); this._permissions = {}; this._userAuthorizeApp(); } return; } [this._permissions] = result; let permission = this._permissions[this.desktopId]; let [levelStr, timeStr] = permission || ['NONE', '0']; this._accuracyLevel = GeoclueAccuracyLevel[levelStr] || GeoclueAccuracyLevel.NONE; this._timesAllowed = Number(timeStr) || 0; if (this._timesAllowed < 3) this._userAuthorizeApp(); else this._completeAuth(); }, _userAuthorizeApp: function() { let name = this._app.get_name(); let appInfo = this._app.get_app_info(); let reason = appInfo.get_string("X-Geoclue-Reason"); this._showAppAuthDialog(name, reason); }, _showAppAuthDialog: function(name, reason) { this._dialog = new GeolocationDialog(name, reason, this.reqAccuracyLevel); let responseId = this._dialog.connect('response', Lang.bind(this, function(dialog, level) { this._dialog.disconnect(responseId); this._accuracyLevel = level; this._completeAuth(); })); this._dialog.open(); }, _completeAuth: function() { if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) { this._accuracyLevel = clamp(this._accuracyLevel, 0, this._maxAccuracyLevel); this._timesAllowed++; } this._saveToPermissionStore(); this._onAuthDone(this._accuracyLevel); }, _saveToPermissionStore: function() { if (this._permStoreProxy == null) return; if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) { let levelStr = accuracyLevelToString(this._accuracyLevel); let dateStr = Math.round(Date.now() / 1000).toString(); this._permissions[this.desktopId] = [levelStr, this._timesAllowed.toString(), dateStr]; } else { delete this._permissions[this.desktopId]; } let data = GLib.Variant.new('av', {}); this._permStoreProxy.SetRemote(APP_PERMISSIONS_TABLE, true, APP_PERMISSIONS_ID, this._permissions, data, function (result, error) { if (error != null) log(error.message); }); }, }); const GeolocationDialog = new Lang.Class({ Name: 'GeolocationDialog', Extends: ModalDialog.ModalDialog, _init: function(name, reason, reqAccuracyLevel) { this.parent({ styleClass: 'geolocation-dialog' }); this.reqAccuracyLevel = reqAccuracyLevel; let mainContentBox = new St.BoxLayout({ style_class: 'geolocation-dialog-main-layout' }); this.contentLayout.add_actor(mainContentBox); let icon = new St.Icon({ style_class: 'geolocation-dialog-icon', icon_name: 'find-location-symbolic', y_align: Clutter.ActorAlign.START }); mainContentBox.add_actor(icon); let messageBox = new St.BoxLayout({ style_class: 'geolocation-dialog-content', vertical: true }); mainContentBox.add_actor(messageBox); this._title = new St.Label({ style_class: 'geolocation-dialog-title headline' }); messageBox.add_actor(this._title); this._desc = new St.Label(); messageBox.add_actor(this._desc); this._reason = new St.Label(); messageBox.add_actor(this._reason); let button = this.addButton({ label: _("Deny Access"), action: Lang.bind(this, this._onDenyClicked), key: Clutter.KEY_Escape }); this.addButton({ label: _("Grant Access"), action: Lang.bind(this, this._onGrantClicked) }); this.setInitialKeyFocus(button); /* Translators: %s is an application name */ this._title.text = _("Give %s access to your location?").format(name); /* Translators: %s is an application name */ this._desc.text = _("%s is requesting access to your location.").format(name); if (reason) this._reason.text = reason; this._reason.visible = (reason != null); }, _onGrantClicked: function() { this.emit('response', this.reqAccuracyLevel); this.close(); }, _onDenyClicked: function() { this.emit('response', GeoclueAccuracyLevel.NONE); this.close(); } }); Signals.addSignalMethods(GeolocationDialog.prototype);