From 7fdad87222a963e57031132acce7c06f4b80e64d Mon Sep 17 00:00:00 2001 From: "kali kaneko (leap communications)" Date: Mon, 6 Sep 2021 21:08:14 +0200 Subject: [ui] transient connecting state --- gui/components/Footer.qml | 121 ++++++++++++++- gui/components/Header.qml | 5 +- gui/components/Home.qml | 4 +- gui/components/Locations.qml | 284 ++++++++++++++++++++++++++++------ gui/components/MainView.qml | 42 +---- gui/components/Preferences.qml | 50 ++++-- gui/components/SignalIcon.qml | 62 ++++++++ gui/components/Splash.qml | 15 +- gui/components/StatusBox.qml | 9 +- gui/components/ThemedPage.qml | 11 ++ gui/components/VPNState.qml | 55 ++++++- gui/components/WrappedRadioButton.qml | 21 +++ gui/gui.qrc | 3 + gui/main.qml | 14 +- gui/themes/themes.js | 7 +- pkg/backend/status.go | 42 ++--- pkg/bitmask/bitmask.go | 2 + pkg/vpn/bonafide/bonafide.go | 11 ++ pkg/vpn/bonafide/gateways.go | 27 ++++ pkg/vpn/openvpn.go | 4 + pkg/vpn/status.go | 4 + 21 files changed, 655 insertions(+), 138 deletions(-) create mode 100644 gui/components/SignalIcon.qml create mode 100644 gui/components/ThemedPage.qml create mode 100644 gui/components/WrappedRadioButton.qml diff --git a/gui/components/Footer.qml b/gui/components/Footer.qml index 2c7c875..7658fa1 100644 --- a/gui/components/Footer.qml +++ b/gui/components/Footer.qml @@ -2,10 +2,13 @@ import QtQuick 2.0 import QtQuick.Controls 2.4 import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.14 +import QtGraphicalEffects 1.0 + +import "../themes/themes.js" as Theme ToolBar { - Material.background: Material.backgroundColor + Material.background: Theme.bgColor Material.foreground: "black" Material.elevation: 0 visible: stackView.depth > 1 && ctx !== undefined ? false : true @@ -17,10 +20,13 @@ ToolBar { ToolButton { id: gwButton - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: 10 - anchors.left: parent.left - anchors.verticalCenterOffset: 5 + visible: hasMultipleGateways() + anchors { + verticalCenter: parent.verticalCenter + leftMargin: 10 + left: parent.left + verticalCenterOffset: 5 + } icon.source: stackView.depth > 1 ? "" : "../resources/globe.svg" onClicked: stackView.push("Locations.qml") } @@ -30,7 +36,8 @@ ToolBar { anchors.left: gwButton.right anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 - text: "Seattle" + text: locationStr() + color: getLocationColor() } Item { @@ -40,6 +47,7 @@ ToolBar { Image { id: bridge + visible: isBridgeSelected() height: 24 width: 24 source: "../resources/bridge.png" @@ -58,6 +66,107 @@ ToolBar { anchors.rightMargin: 20 anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 5 + // TODO refactor with SignalIcon + ColorOverlay{ + anchors.fill: gwQuality + source: gwQuality + color: getSignalColor() + antialiasing: true + } + } + } + + function getSignalColor() { + if (ctx && ctx.status == "on") { + return "green" + } else { + return "black" + } + } + + StateGroup { + state: ctx ? ctx.status : "off" + states: [ + State { + name: "on" + PropertyChanges { + target: gwQuality + source: "../resources/reception-4.svg" + } + }, + State { + name: "off" + PropertyChanges { + target: gwQuality + source: "../resources/reception-0.svg" + } + } + ] + } + + function locationStr() { + if (ctx && ctx.status == "on") { + if (ctx.currentLocation && ctx.currentCountry) { + let s = ctx.currentLocation + ", " + ctx.currentCountry + if (root.selectedGateway == "auto") { + s = "🗲 " + s + } + return s + } + } + if (root.selectedGateway == "auto") { + if (ctx && ctx.locations && ctx.bestLocation) { + return "🗲 " + getCanonicalLocation(ctx.bestLocation) + } else { + return qsTr("Recommended") + } + } + if (ctx && ctx.locations && ctx.locationLabels) { + return getCanonicalLocation(root.selectedGateway) + } + } + + // returns the composite of Location, CC + function getCanonicalLocation(label) { + try { + let loc = ctx.locationLabels[label] + return loc[0] + ", " + loc[1] + } catch(e) { + return "unknown" + } + } + + function getLocationColor() { + if (ctx && ctx.status == "on") { + return "black" + } else { + // TODO darker gray + return "gray" + } + } + + function hasMultipleGateways() { + let provider = getSelectedProvider(providers) + if (provider == "riseup") { + return true + } else { + if (!ctx) { + return false + } + return ctx.locations.length > 0 + } + } + + function getSelectedProvider(providers) { + let obj = JSON.parse(providers.getJson()) + return obj['default'] + } + + function isBridgeSelected() { + if (ctx && ctx.transport == "obfs4") { + return true + } else { + return false } } } diff --git a/gui/components/Header.qml b/gui/components/Header.qml index 92f4bdd..6682a28 100644 --- a/gui/components/Header.qml +++ b/gui/components/Header.qml @@ -3,10 +3,12 @@ import QtQuick.Controls 2.4 import QtQuick.Dialogs 1.2 import QtQuick.Controls.Material 2.1 +import "../themes/themes.js" as Theme + ToolBar { visible: stackView.depth > 1 Material.foreground: Material.Black - Material.background: "#ffffff" + Material.background: Theme.bgColor Material.elevation: 0 contentHeight: settingsButton.implicitHeight @@ -27,6 +29,7 @@ ToolBar { Label { text: stackView.currentItem.title + font.bold: true anchors.centerIn: parent } } diff --git a/gui/components/Home.qml b/gui/components/Home.qml index c9eab2a..099bc23 100644 --- a/gui/components/Home.qml +++ b/gui/components/Home.qml @@ -3,6 +3,6 @@ import QtQuick.Controls 2.2 import QtGraphicalEffects 1.0 Page { - - StatusBox {} + StatusBox { + } } diff --git a/gui/components/Locations.qml b/gui/components/Locations.qml index d3e0f5a..955da26 100644 --- a/gui/components/Locations.qml +++ b/gui/components/Locations.qml @@ -1,62 +1,256 @@ import QtQuick 2.9 import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.14 +import QtGraphicalEffects 1.0 import "../themes/themes.js" as Theme -Page { +/* TODO + [ ] build a better gateway object list (location, user-friendly, country, bridge, etc) + [ ] ui: mark manual override icon somehow? + [ ] ui: auto radiobutton should use also the bridge icon + [ ] corner case: manual override, not full list yet + [ ] persist bridges + [ ] persist manual selection + [ ] display the location we know + [ ] corner case: user selects bridges with manual selection + (I think the backend should discard any manual selection when selecting bridges... + unless the current selection provides the bridge, in which case we can maintain it) + */ + +ThemedPage { + + id: locationPage title: qsTr("Select Location") - ListView { - id: gwList - focus: true - currentIndex: -1 - anchors.fill: parent - spacing: 1 + // TODO add ScrollIndicator + // https://doc.qt.io/qt-5.12//qml-qtquick-controls2-scrollindicator.html - delegate: ItemDelegate { - id: loc - Rectangle { - width: parent.width - height: 1 - color: Theme.borderColor - } - width: parent.width - text: model.text - highlighted: ListView.isCurrentItem - icon.color: "transparent" - icon.source: model.icon - onClicked: { - model.triggered() - stackView.pop() - } - MouseArea { - property var onMouseAreaClicked: function () { - parent.clicked() - } - id: mouseArea - anchors.fill: loc - cursorShape: Qt.PointingHandCursor - onReleased: { - onMouseAreaClicked() + //: this is in the radio button for the auto selection + property var autoSelectionLabel: qsTr("Automatically use best connection") + //: Location Selection: label for radio buttons that selects manually + property var manualSelectionLabel: qsTr("Manually select") + //: A little display to signal that the clicked gateway is being switched to + property var switchingLocationLabel: qsTr("Switching gateways...") + //: Subtitle to explain that only bridge locations are shown in the selector + property var onlyBridgesWarning: qsTr("Only locations with bridges") + + property bool switching: false + + ButtonGroup { + id: locsel + } + + Rectangle { + id: autoBox + width: root.width * 0.90 + height: 100 + radius: 10 + color: "white" + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + margins: 10 + } + Rectangle { + anchors { + fill: parent + margins: 10 + } + Label { + id: recommendedLabel + //: Location Selection: label for radio button that selects automatically + text: qsTr("Recommended") + font.bold: true + } + WrappedRadioButton { + id: autoRadioButton + anchors.top: recommendedLabel.bottom + text: getAutoLabel() + ButtonGroup.group: locsel + checked: false + onClicked: { + root.selectedGateway = "auto" + console.debug("Selected gateway: auto") + backend.useAutomaticGateway() } } } + } - model: ListModel { - ListElement { - text: qsTr("Paris") - triggered: function () {} - icon: "../resources/reception-4.svg" + Rectangle { + id: manualBox + visible: root.locationsModel.length > 0 + width: root.width * 0.90 + height: getManualBoxHeight() + radius: 10 + color: Theme.fgColor + anchors { + horizontalCenter: parent.horizontalCenter + top: autoBox.bottom + margins: 10 + } + Rectangle { + anchors { + fill: parent + margins: 10 + } + Label { + id: manualLabel + text: manualSelectionLabel + font.bold: true + } + Label { + id: bridgeWarning + text: onlyBridgesWarning + color: "gray" + visible: isBridgeSelected() + wrapMode: Text.Wrap + anchors { + topMargin: 5 + top: manualLabel.bottom + } + font.pixelSize: Theme.fontSize - 3 } - ListElement { - text: qsTr("Montreal") - triggered: function () {} - icon: "../resources/reception-4.svg" + ColumnLayout { + id: gatewayListColumn + width: parent.width + spacing: 1 + anchors.top: getManualAnchor() + + Repeater { + id: gwManualSelectorList + width: parent.width + model: root.locationsModel + + RowLayout { + width: parent.width + WrappedRadioButton { + text: getLocationLabel(modelData) + location: modelData + ButtonGroup.group: locsel + checked: false + enabled: locationPage.switching ? false : true + onClicked: { + if (ctx.status == "on") { + locationPage.switching = true + } + root.selectedGateway = location + backend.useLocation(location) + } + } + Item { + Layout.fillWidth: true + } + Image { + height: 16 + width: 16 + visible: isBridgeSelected() + source: "../resources/bridge.png" + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 10 + } + SignalIcon { + // TODO mocked! + quality: getSignalFor(modelData) + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 20 + } + } + } } - ListElement { - text: qsTr("Seattle") - triggered: function () {} - icon: "../resources/reception-2.svg" + } + } + + StateGroup { + states: [ + State { + when: locationPage.switching && ctx.status != "on" + PropertyChanges { + target: manualLabel + text: switchingLocationLabel + } + }, + State { + when: ctx && ctx.status == "on" + PropertyChanges { + target: manualLabel + text: manualSelectionLabel + } + StateChangeScript { + script: { + locationPage.switching = false + } + } + } + ] + } + + function getAutoLabel() { + let l = autoSelectionLabel + if (ctx && ctx.locations && ctx.bestLocation) { + let best = ctx.locationLabels[ctx.bestLocation] + let label = best[0] + ", " + best[1] + l += " (" + label + ")" + } + return l + } + + function getLocationLabel(location) { + if (!ctx) { return ""} + let l = ctx.locationLabels[location] + return l[0] + ", " + l[1] + } + + function getManualBoxHeight() { + let h = gatewayListColumn.height + manualLabel.height + if (bridgeWarning.visible) { + h += bridgeWarning.height + } + return h + 15 + } + + function getSignalFor(location) { + switch(location) { + case "amsterdam": + case "paris": + return "good" + case "newyork": + return "medium" + case "montreal": + return "medium" + default: + return "low" + } + } + + function isBridgeSelected() { + if (ctx && ctx.transport == "obfs4") { + return true + } else { + return false + } + } + + function getManualAnchor() { + if (isBridgeSelected()) { + return bridgeWarning.bottom + } else { + return manualLabel.bottom + } + } + + Component.onCompleted: { + if (root.selectedGateway == "auto") { + autoRadioButton.checked = true; + } else { + let match = false + for (var i=1; i themes/themes.js components/MainView.qml + components/ThemedPage.qml components/Splash.qml components/Home.qml components/Header.qml components/Footer.qml + components/WrappedRadioButton.qml components/StatusBox.qml components/Spinner.qml components/Systray.qml components/Help.qml components/Locations.qml + components/SignalIcon.qml components/Preferences.qml components/BoldLabel.qml components/LightLabel.qml diff --git a/gui/main.qml b/gui/main.qml index f847377..515330a 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -9,9 +9,9 @@ - [x] font: monserrat - [x] nested states - [x] splash init errors - - [ ] minimize/hide from systray - - [ ] gateway selector + - [.] gateway selector - [ ] bridges + - [ ] minimize/hide from systray - [ ] control actions from systray - [ ] add gateway to systray - [ ] donation dialog @@ -43,8 +43,13 @@ ApplicationWindow { property var ctx property var error: "" + + // TODO can move properties to some state sub-item to unclutter property bool isDonationService: false property bool showDonationReminder: false + property var locationsModel: [] + // TODO get from persistance + property var selectedGateway: "auto" property var icons: { "off": "qrc:/assets/icon/png/white/vpn_off.png", @@ -84,6 +89,9 @@ ApplicationWindow { console.debug(j) } ctx = JSON.parse(j) + if (ctx != undefined) { + locationsModel = Object.keys(ctx.locations) + } if (ctx.errors) { console.debug("errors, setting root.error") root.error = ctx.errors @@ -96,7 +104,7 @@ ApplicationWindow { if (ctx.donateDialog == 'true') { showDonationReminder = true; } - //gwSelector.model = Object.keys(ctx.locations) + // TODO check donation //if (needsDonate && !shownDonate) { // donate.visible = true; diff --git a/gui/themes/themes.js b/gui/themes/themes.js index 265af9a..c04ef24 100644 --- a/gui/themes/themes.js +++ b/gui/themes/themes.js @@ -48,4 +48,9 @@ const blueButton = { "focusBorder": blueFocusBorder, }; -const bgColor = "white"; +const bgColor = "#f3f3f3"; +const fgColor = "#ffffff"; + +const signalGood = "green"; +const signalMedium = "orange"; +const signalLow = "red"; diff --git a/pkg/backend/status.go b/pkg/backend/status.go index 0ffd853..c5f79d1 100644 --- a/pkg/backend/status.go +++ b/pkg/backend/status.go @@ -32,24 +32,27 @@ var updateMutex sync.Mutex // them. type connectionCtx struct { - AppName string `json:"appName"` - Provider string `json:"provider"` - TosURL string `json:"tosURL"` - HelpURL string `json:"helpURL"` - AskForDonations bool `json:"askForDonations"` - DonateDialog bool `json:"donateDialog"` - DonateURL string `json:"donateURL"` - LoginDialog bool `json:"loginDialog"` - LoginOk bool `json:"loginOk"` - Version string `json:"version"` - Errors string `json:"errors"` - Status status `json:"status"` - Locations map[string]float64 `json:"locations"` - CurrentGateway string `json:"currentGateway"` - CurrentLocation string `json:"currentLocation"` - CurrentCountry string `json:"currentCountry"` - ManualLocation bool `json:"manualLocation"` - IsReady bool `json:"isReady"` + AppName string `json:"appName"` + Provider string `json:"provider"` + TosURL string `json:"tosURL"` + HelpURL string `json:"helpURL"` + AskForDonations bool `json:"askForDonations"` + DonateDialog bool `json:"donateDialog"` + DonateURL string `json:"donateURL"` + LoginDialog bool `json:"loginDialog"` + LoginOk bool `json:"loginOk"` + Version string `json:"version"` + Errors string `json:"errors"` + Status status `json:"status"` + Locations map[string]float64 `json:"locations"` + LocationLabels map[string][]string `json:"locationLabels"` + CurrentGateway string `json:"currentGateway"` + CurrentLocation string `json:"currentLocation"` + CurrentCountry string `json:"currentCountry"` + BestLocation string `json:"bestLocation"` + Transport string `json:"transport"` + ManualLocation bool `json:"manualLocation"` + IsReady bool `json:"isReady"` bm bitmask.Bitmask autostart bitmask.Autostart cfg *config.Config @@ -60,9 +63,12 @@ func (c *connectionCtx) toJson() ([]byte, error) { if c.bm != nil { transport := c.bm.GetTransport() c.Locations = c.bm.ListLocationFullness(transport) + c.LocationLabels = c.bm.ListLocationLabels(transport) c.CurrentGateway = c.bm.GetCurrentGateway() c.CurrentLocation = c.bm.GetCurrentLocation() c.CurrentCountry = c.bm.GetCurrentCountry() + c.BestLocation = c.bm.GetBestLocation(transport) + c.Transport = transport c.ManualLocation = c.bm.IsManualLocation() } defer statusMutex.Unlock() diff --git a/pkg/bitmask/bitmask.go b/pkg/bitmask/bitmask.go index 364312e..1d7217c 100644 --- a/pkg/bitmask/bitmask.go +++ b/pkg/bitmask/bitmask.go @@ -28,6 +28,8 @@ type Bitmask interface { InstallHelpers() error VPNCheck() (helpers bool, priviledge bool, err error) ListLocationFullness(protocol string) map[string]float64 + ListLocationLabels(protocol string) map[string][]string + GetBestLocation(protocol string) string UseGateway(name string) UseAutomaticGateway() GetTransport() string diff --git a/pkg/vpn/bonafide/bonafide.go b/pkg/vpn/bonafide/bonafide.go index a9a7d85..cff5fc2 100644 --- a/pkg/vpn/bonafide/bonafide.go +++ b/pkg/vpn/bonafide/bonafide.go @@ -278,6 +278,10 @@ func (b *Bonafide) ListLocationFullness(transport string) map[string]float64 { return b.gateways.listLocationFullness(transport) } +func (b *Bonafide) ListLocationLabels(transport string) map[string][]string { + return b.gateways.listLocationLabels(transport) +} + func (b *Bonafide) SetManualGateway(label string) { b.gateways.setUserChoice(label) } @@ -286,6 +290,13 @@ func (b *Bonafide) SetAutomaticGateway() { b.gateways.setAutomaticChoice() } +func (b *Bonafide) GetBestLocation(transport string) string { + if b.gateways == nil { + return "" + } + return b.gateways.getBestLocation(transport, b.tzOffsetHours) +} + func (b *Bonafide) IsManualLocation() bool { if b.gateways == nil { return false diff --git a/pkg/vpn/bonafide/gateways.go b/pkg/vpn/bonafide/gateways.go index 53ab320..4299bb2 100644 --- a/pkg/vpn/bonafide/gateways.go +++ b/pkg/vpn/bonafide/gateways.go @@ -113,6 +113,20 @@ func (p *gatewayPool) listLocationFullness(transport string) map[string]float64 return cm } +/* returns a map of location: labels for the ui to use */ +func (p *gatewayPool) listLocationLabels(transport string) map[string][]string { + cm := make(map[string][]string) + locations := p.getLocations() + if len(locations) == 0 { + return cm + } + for _, loc := range locations { + current := p.locations[loc] + cm[loc] = []string{current.Name, current.CountryCode} + } + return cm +} + /* this method should only be used if we have no usable menshen list. */ func (p *gatewayPool) getRandomGatewaysByLocation(location, transport string) ([]Gateway, error) { if !p.isValidLocation(location) { @@ -274,6 +288,19 @@ func (p *gatewayPool) getBest(transport string, tz, max int) ([]Gateway, error) } } +/* returns the location for the first recommended gateway */ +func (p *gatewayPool) getBestLocation(transport string, tz int) string { + best, err := p.getBest(transport, tz, 1) + if err != nil { + return "" + } + if len(best) != 1 { + return "" + } + return best[0].Location + +} + func (p *gatewayPool) getAll(transport string, tz int) ([]Gateway, error) { if len(p.recommended) != 0 { return p.getGatewaysFromMenshen(transport, 999) diff --git a/pkg/vpn/openvpn.go b/pkg/vpn/openvpn.go index b15530b..fe10b69 100644 --- a/pkg/vpn/openvpn.go +++ b/pkg/vpn/openvpn.go @@ -326,6 +326,10 @@ func (b *Bitmask) ListLocationFullness(transport string) map[string]float64 { return b.bonafide.ListLocationFullness(transport) } +func (b *Bitmask) ListLocationLabels(transport string) map[string][]string { + return b.bonafide.ListLocationLabels(transport) +} + // UseGateway selects a gateway, by label, as the default gateway func (b *Bitmask) UseGateway(label string) { b.bonafide.SetManualGateway(label) diff --git a/pkg/vpn/status.go b/pkg/vpn/status.go index 0b04c3b..88735e6 100644 --- a/pkg/vpn/status.go +++ b/pkg/vpn/status.go @@ -103,6 +103,10 @@ func (b *Bitmask) GetCurrentCountry() string { return b.onGateway.CountryCode } +func (b *Bitmask) GetBestLocation(transport string) string { + return b.bonafide.GetBestLocation(transport) +} + func (b *Bitmask) IsManualLocation() bool { return b.bonafide.IsManualLocation() } -- cgit v1.2.3