From 2fa8b2c5f9fa2592dc5877d59915a5305df3c0f9 Mon Sep 17 00:00:00 2001 From: miruka Date: Wed, 19 Aug 2020 00:17:24 -0400 Subject: [PATCH] Add a public server list to the initial login page --- TODO.md | 4 + src/backend/backend.py | 146 ++++++++++++++- src/backend/models/items.py | 24 +++ src/gui/Base/HBox.qml | 3 +- src/gui/Pages/AddAccount/ServerBrowser.qml | 197 ++++++++++++-------- src/gui/Pages/AddAccount/ServerDelegate.qml | 83 +++++++++ src/gui/Utils.qml | 4 +- src/icons/thin/server-connect.svg | 3 - src/icons/thin/server-ping-bad.svg | 5 + src/icons/thin/server-ping-fail.svg | 3 + src/icons/thin/server-ping-good.svg | 3 + src/icons/thin/server-ping-medium.svg | 5 + src/icons/thin/server-visit-website.svg | 5 + 13 files changed, 402 insertions(+), 83 deletions(-) create mode 100644 src/gui/Pages/AddAccount/ServerDelegate.qml delete mode 100644 src/icons/thin/server-connect.svg create mode 100644 src/icons/thin/server-ping-bad.svg create mode 100644 src/icons/thin/server-ping-fail.svg create mode 100644 src/icons/thin/server-ping-good.svg create mode 100644 src/icons/thin/server-ping-medium.svg create mode 100644 src/icons/thin/server-visit-website.svg diff --git a/TODO.md b/TODO.md index f5631731..b17392d6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,9 @@ # TODO +- clicking cancel on SSO "waiting" box doesn't do anything the first time +- spam alt+shift+a when starting app on server browser → segfault +- remove items.Device +- register tab for sso servers? - sever list - cursor shape in HBox/HTabbedBox pages over fields - login with account already added → infinite spinner in room list diff --git a/src/backend/backend.py b/src/backend/backend.py index 1d3d0c64..ad6bc1d7 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -2,12 +2,17 @@ import asyncio import logging as log +import math import os +import re import sys +import time import traceback +from datetime import datetime from pathlib import Path from typing import Any, DefaultDict, Dict, List, Optional, Tuple +import aiohttp from appdirs import AppDirs import nio @@ -18,7 +23,7 @@ from .matrix_client import MatrixClient from .media_cache import MediaCache from .models import SyncId from .models.filters import FieldSubstringFilter -from .models.items import Account, Event +from .models.items import Account, Event, Homeserver, PingStatus from .models.model import Model from .models.model_store import ModelStore from .presence import Presence @@ -107,6 +112,9 @@ class Backend: self._sso_server: Optional[SSOServer] = None self._sso_server_task: Optional[asyncio.Future] = None + self._ping_tasks: Dict[str, asyncio.Future] = {} + self._stability_tasks: Dict[str, asyncio.Future] = {} + self.profile_cache: Dict[str, nio.ProfileGetResponse] = {} self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \ DefaultDict(asyncio.Lock) # {user_id: lock} @@ -474,3 +482,139 @@ class Backend: if device.ed25519 == ed25519_key: client.blacklist_device(device) + + + async def _ping_homeserver( + self, session: aiohttp.ClientSession, homeserver_url: str, + ) -> None: + """Ping a homeserver present in our model and set its `ping` field.""" + + item = self.models["homeservers"][homeserver_url] + times = [] + + for i in range(16): + start = time.time() + + try: + await session.get(f"{homeserver_url}/_matrix/client/versions") + except Exception as err: + log.warning("Failed pinging %s: %r", homeserver_url, err) + item.status = PingStatus.Failed + return + + times.append(round((time.time() - start) * 1000)) + + if i == 7 or i == 15: + item.set_fields( + ping=sum(times) // len(times), status=PingStatus.Done, + ) + + + async def _get_homeserver_stability( + self, + session: aiohttp.ClientSession, + homeserver_url: str, + uptimerobot_id: int, + ) -> None: + api = "https://matrixservers.anchel.nl/api/getMonitor/wkMJmFGvo2?m={}" + + response = await session.get(api.format(uptimerobot_id)) + logs = (await response.json())["monitor"]["logs"] + stability = 100.0 + + for period in logs: + started_at = datetime.fromtimestamp(period["time"]) + time_since_now = datetime.now() - started_at + + if time_since_now.days > 30 or period["class"] != "danger": + continue + + lasted_hours, lasted_mins = [ + int(x.split()[0]) for x in period["duration"].split(", ") + ] + lasted_mins += lasted_hours * 60 + stability -= ( + (lasted_mins * stability / 1000) / + max(1, time_since_now.days / 3) + ) + + self.models["homeservers"][homeserver_url].stability = stability + + + async def _add_homeserver_item( + self, + session: aiohttp.ClientSession, + homeserver_url: str, + uptimerobot_id: int, + **fields, + ) -> Homeserver: + """Add homeserver to our model & start info-gathering tasks.""" + + if not re.match(r"^https?://.+", homeserver_url): + homeserver_url = f"https://{homeserver_url}" + + if fields.get("country") == "USA": + fields["country"] = "United States" + + if homeserver_url in self._ping_tasks: + self._ping_tasks[homeserver_url].cancel() + + if homeserver_url in self._stability_tasks: + self._stability_tasks[homeserver_url].cancel() + + item = Homeserver(id=homeserver_url, **fields) + self.models["homeservers"][homeserver_url] = item + + self._ping_tasks[homeserver_url] = asyncio.ensure_future( + self._ping_homeserver(session, homeserver_url), + ) + + self._stability_tasks[homeserver_url] = asyncio.ensure_future( + self._get_homeserver_stability( + session, item.id, uptimerobot_id, + ), + ) + + return item + + + async def fetch_homeservers(self) -> None: + """Retrieve a list of public homeservers and add them to our model.""" + + api_list = "https://publiclist.anchel.nl/staticlist.json" + + tmout = aiohttp.ClientTimeout(total=20) + session = aiohttp.ClientSession(raise_for_status=True, timeout=tmout) + response = await session.get(api_list) + data = (await response.json())["staticlist"] + + await self._add_homeserver_item( + session = session, + homeserver_url = "https://matrix-client.matrix.org", + name = "matrix.org", + site_url = "https://matrix.org", + country = "Cloudflare", + uptimerobot_id = 783115140, + ) + + await self._add_homeserver_item( + session = session, + homeserver_url = "https://mozilla.modular.im", + name = "mozilla.org", + site_url = "https://mozilla.org", + country = "United States", + uptimerobot_id = 784321494, + ) + + for server in data: + if server["homeserver"].startswith("http://"): # insecure server + continue + + await self._add_homeserver_item( + session = session, + homeserver_url = server["homeserver"], + name = server["name"], + site_url = server["url"], + country = server["country"], + uptimerobot_id = server["uptrid"], + ) diff --git a/src/backend/models/items.py b/src/backend/models/items.py index 8b591cea..9ec2f0b8 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -30,6 +30,30 @@ class TypeSpecifier(AutoStrEnum): MembershipChange = auto() +class PingStatus(AutoStrEnum): + """Enum for the status of a homeserver ping operation.""" + + Done = auto() + Pinging = auto() + Failed = auto() + + +@dataclass +class Homeserver(ModelItem): + """A homeserver we can connect to. The `id` field is the server's URL.""" + + id: str = field() + name: str = field() + site_url: str = field() + country: str = field() + ping: int = -1 + status: PingStatus = PingStatus.Pinging + stability: float = -1 + + def __lt__(self, other: "Homeserver") -> bool: + return (self.name.lower(), self.id) < (other.name.lower(), other.id) + + @dataclass class Account(ModelItem): """A logged in matrix account.""" diff --git a/src/gui/Base/HBox.qml b/src/gui/Base/HBox.qml index 4fd87cbd..a78fc771 100644 --- a/src/gui/Base/HBox.qml +++ b/src/gui/Base/HBox.qml @@ -3,8 +3,9 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 -HFlickableColumnPage { +HColumnPage { implicitWidth: Math.min(parent.width, theme.controls.box.defaultWidth) + padding: theme.spacing background: Rectangle { color: theme.controls.box.background diff --git a/src/gui/Pages/AddAccount/ServerBrowser.qml b/src/gui/Pages/AddAccount/ServerBrowser.qml index e8d8db8a..e673b95a 100644 --- a/src/gui/Pages/AddAccount/ServerBrowser.qml +++ b/src/gui/Pages/AddAccount/ServerBrowser.qml @@ -2,12 +2,13 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 +import "../.." import "../../Base" import "../../Base/Buttons" import "../../PythonBridge" HBox { - id: page + id: box property string acceptedUserUrl: "" property string acceptedUrl: "" @@ -15,22 +16,35 @@ HBox { property string saveName: "serverBrowser" property var saveProperties: ["acceptedUserUrl"] + property string loadingIconStep: "server-ping-bad" + property Future connectFuture: null + property Future fetchServersFuture: null signal accepted() - function takeFocus() { serverField.item.forceActiveFocus() } + function takeFocus() { serverField.item.field.forceActiveFocus() } + + function fetchServers() { + fetchServersFuture = py.callCoro("fetch_homeservers", [], () => { + fetchServersFuture = null + }, (type, args, error, traceback) => { + fetchServersFuture = null + // TODO + print( traceback) + }) + } function connect() { if (connectFuture) connectFuture.cancel() connectTimeout.restart() - const args = [serverField.item.cleanText] + const args = [serverField.item.field.cleanText] connectFuture = py.callCoro("server_info", args, ([url, flows]) => { connectTimeout.stop() - errorMessage.text = "" - connectFuture = null + serverField.errorLabel.text = "" + connectFuture = null if (! ( flows.includes("m.login.password") || @@ -39,7 +53,7 @@ HBox { flows.includes("m.login.token") ) )) { - errorMessage.text = + serverField.errorLabel.text = qsTr("No supported sign-in method for this homeserver.") return } @@ -63,106 +77,137 @@ HBox { py.showError(type, traceback, uuid) - errorMessage.text = text + serverField.errorLabel.text = text }) } - function cancel() { - if (page.connectFuture) return - connectTimeout.stop() - connectFuture.cancel() - connectFuture = null + padding: 0 + implicitWidth: theme.controls.box.defaultWidth * 1.25 + contentHeight: Math.min( + window.height, + Math.max( + serverList.contentHeight, + // busyIndicatorLoader.height + theme.spacing * 2, TODO + ) + ) + + header: HLabel { + text: qsTr( + "Choose a homeserver to create your account on, or the " + + "server on which you made an account to sign in to:" + ) + wrapMode: HLabel.Wrap + padding: theme.spacing } + footer: HLabeledItem { + id: serverField - footer: AutoDirectionLayout { - ApplyButton { - id: applyButton - enabled: serverField.item.cleanText && ! serverField.item.error - text: qsTr("Connect") - icon.name: "server-connect" - loading: page.connectFuture !== null - disableWhileLoading: false - onClicked: page.connect() - } + readonly property bool knownServerChosen: + serverList.model.find(item.cleanText) !== null - CancelButton { - id: cancelButton - enabled: page.connectFuture !== null - onClicked: page.cancel() + label.text: qsTr("Homeserver address:") + label.topPadding: theme.spacing + label.leftPadding: label.topPadding + label.rightPadding: label.topPadding + errorLabel.leftPadding: label.topPadding + errorLabel.rightPadding: label.topPadding + errorLabel.bottomPadding: label.topPadding + + Layout.fillWidth: true + Layout.margins: theme.spacing + + HRowLayout { + readonly property alias field: field + readonly property alias apply: apply + + width: parent.width + + HTextField { + id: field + + readonly property string cleanText: + text.toLowerCase().trim().replace(/\/+$/, "") + + error: text && ! /https?:\/\/.+/.test(cleanText) + defaultText: window.getState( + box, "acceptedUserUrl", "", + ) + placeholderText: "https://example.org" + + Layout.fillWidth: true + Layout.fillHeight: true + } + + HButton { + id: apply + enabled: field.cleanText && ! field.error + icon.name: "apply" + icon.color: theme.colors.positiveBackground + loading: box.connectFuture !== null + disableWhileLoading: false + onClicked: box.connect() + + Layout.fillHeight: true + } } } - onKeyboardAccept: if (applyButton.enabled) page.connect() - onKeyboardCancel: if (cancelButton.enabled) page.cancel() + onKeyboardAccept: if (serverField.item.apply.enabled) box.connect() onAccepted: window.saveState(this) Timer { id: connectTimeout interval: 30 * 1000 onTriggered: { - errorMessage.text = + serverField.errorLabel.text = serverField.knownServerChosen ? qsTr("This homeserver seems unavailable. Verify your inter" + - "net connection or try again in a few minutes.") : + "net connection or try again later.") : qsTr("This homeserver seems unavailable. Verify the " + "entered address, your internet connection or try " + - "again in a few minutes.") + "again later.") } } - HLabeledItem { - id: serverField - - // 2019-11-11 https://www.hello-matrix.net/public_servers.php - readonly property var knownServers: [ - "https://matrix.org", - "https://chat.weho.st", - "https://tchncs.de", - "https://chat.privacytools.io", - "https://hackerspaces.be", - "https://matrix.allmende.io", - "https://feneas.org", - "https://junta.pl", - "https://perthchat.org", - "https://matrix.tedomum.net", - "https://converser.eu", - "https://ru-matrix.org", - "https://matrix.sibnsk.net", - "https://alternanet.fr", - ] - - readonly property bool knownServerChosen: - knownServers.includes(item.cleanText) - - label.text: qsTr("Homeserver:") - - Layout.fillWidth: true - - HTextField { - readonly property string cleanText: - text.toLowerCase().trim().replace(/\/+$/, "") - - width: parent.width - error: ! /https?:\/\/.+/.test(cleanText) - defaultText: - window.getState(page, "acceptedUserUrl", "https://matrix.org") - } + Timer { + interval: 1000 + running: fetchServersFuture === null && serverList.count === 0 + repeat: true + triggeredOnStart: true + onTriggered: box.fetchServers() } - HLabel { - id: errorMessage - wrapMode: HLabel.Wrap - horizontalAlignment: Text.AlignHCenter - color: theme.colors.errorText + Timer { + interval: theme.animationDuration * 2 + running: true + repeat: true + onTriggered: + box.loadingIconStep = "server-ping-" + ( + box.loadingIconStep === "server-ping-bad" ? "medium" : + box.loadingIconStep === "server-ping-medium" ? "good" : + "bad" + ) + } - visible: Layout.maximumHeight > 0 - Layout.maximumHeight: text ? implicitHeight : 0 - Behavior on Layout.maximumHeight { HNumberAnimation {} } + HListView { + id: serverList + clip: true + model: ModelStore.get("homeservers") + + delegate: ServerDelegate { + width: serverList.width + loadingIconStep: box.loadingIconStep + onClicked: { + serverField.item.field.text = model.id + serverField.item.apply.clicked() + } + } Layout.fillWidth: true + Layout.fillHeight: true } } diff --git a/src/gui/Pages/AddAccount/ServerDelegate.qml b/src/gui/Pages/AddAccount/ServerDelegate.qml new file mode 100644 index 00000000..0b5dcc78 --- /dev/null +++ b/src/gui/Pages/AddAccount/ServerDelegate.qml @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import "../../Base" +import "../../Base/HTile" + +HTile { + id: root + + property string loadingIconStep + + + contentOpacity: model.status === "Failed" ? 0.3 : 1 // XXX + rightPadding: 0 + + contentItem: ContentRow { + tile: root + spacing: 0 + + HIcon { + id: signalIcon + + svgName: + model.status === "Failed" ? "server-ping-fail" : + model.status === "Pinging" ? root.loadingIconStep : + model.ping < 400 ? "server-ping-good" : + model.ping < 800 ? "server-ping-medium" : + "server-ping-bad" + + colorize: + model.status === "Failed" ? theme.colors.negativeBackground : + model.status === "Pinging" ? theme.colors.accentBackground : + model.ping < 400 ? theme.colors.positiveBackground : + model.ping < 800 ? theme.colors.middleBackground : + theme.colors.negativeBackground + + Layout.fillHeight: true + Layout.rightMargin: theme.spacing + + Behavior on colorize { HColorAnimation {} } + } + + HColumnLayout { + Layout.rightMargin: theme.spacing + + TitleLabel { + text: model.name + } + + SubtitleLabel { + tile: root + text: model.country + } + } + + TitleRightInfoLabel { + tile: root + font.pixelSize: theme.fontSize.normal + + text: + model.stability === -1 ? + "" : + qsTr("%1%").arg(Math.max(0, parseInt(model.stability, 10))) + + color: + model.stability >= 95 ? theme.colors.positiveText : + model.stability >= 85 ? theme.colors.warningText : + theme.colors.errorText + } + + HButton { + icon.name: "server-visit-website" + toolTip.text: qsTr("Visit website") + backgroundColor: "transparent" + onClicked: Qt.openUrlExternally(model.site_url) + + Layout.fillHeight: true + } + } + + Behavior on contentOpacity { HNumberAnimation {} } +} diff --git a/src/gui/Utils.qml b/src/gui/Utils.qml index 135f38e8..af488926 100644 --- a/src/gui/Utils.qml +++ b/src/gui/Utils.qml @@ -397,8 +397,8 @@ QtObject { } - function round(floatNumber) { - return parseFloat(floatNumber.toFixed(2)) + function round(floatNumber, decimalDigits=2) { + return parseFloat(floatNumber.toFixed(decimalDigits)) } diff --git a/src/icons/thin/server-connect.svg b/src/icons/thin/server-connect.svg deleted file mode 100644 index c5d41de2..00000000 --- a/src/icons/thin/server-connect.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/icons/thin/server-ping-bad.svg b/src/icons/thin/server-ping-bad.svg new file mode 100644 index 00000000..00ca5339 --- /dev/null +++ b/src/icons/thin/server-ping-bad.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/thin/server-ping-fail.svg b/src/icons/thin/server-ping-fail.svg new file mode 100644 index 00000000..e4728028 --- /dev/null +++ b/src/icons/thin/server-ping-fail.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/thin/server-ping-good.svg b/src/icons/thin/server-ping-good.svg new file mode 100644 index 00000000..e0f465c5 --- /dev/null +++ b/src/icons/thin/server-ping-good.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/thin/server-ping-medium.svg b/src/icons/thin/server-ping-medium.svg new file mode 100644 index 00000000..c3c02777 --- /dev/null +++ b/src/icons/thin/server-ping-medium.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/thin/server-visit-website.svg b/src/icons/thin/server-visit-website.svg new file mode 100644 index 00000000..f1b34d3d --- /dev/null +++ b/src/icons/thin/server-visit-website.svg @@ -0,0 +1,5 @@ + + + + +