8a8539ee67
The Config module is shared between the main process, D-Bus services and tests, which previously prevented it from being ported to ESM. The previous commit removed the last outstanding blocker, so we can now port the last remaining module. Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2880>
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
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'),
|
|
});
|
|
|
|
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.set_size_request(600, 450);
|
|
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._webContext.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]);
|