feat: early access

This commit is contained in:
James Houlahan
2020-12-03 15:36:02 +01:00
parent eccad4bbfd
commit d2066173f0
22 changed files with 235 additions and 72 deletions

View File

@ -180,11 +180,11 @@ func New( // nolint[funlen]
updater := updater.New(
cm,
installer,
settingsObj,
kr,
semver.MustParse(constants.Version),
updateURLName,
runtime.GOOS,
settingsObj.GetFloat64(settings.RolloutKey),
)
return &Base{

View File

@ -20,6 +20,7 @@ package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"sync"
@ -135,3 +136,7 @@ func (p *keyValueStore) SetBool(key string, value bool) {
func (p *keyValueStore) SetInt(key string, value int) {
p.Set(key, strconv.Itoa(value))
}
func (p *keyValueStore) SetFloat64(key string, value float64) {
p.Set(key, fmt.Sprintf("%v", value))
}

View File

@ -40,6 +40,7 @@ const (
CookiesKey = "cookies"
ReportOutgoingNoEncKey = "report_outgoing_email_without_encryption"
LastVersionKey = "last_used_version"
UpdateChannelKey = "update_channel"
RolloutKey = "rollout"
)
@ -75,6 +76,7 @@ func (s *Settings) setDefaultValues() {
s.setDefault(AutoUpdateKey, "true")
s.setDefault(ReportOutgoingNoEncKey, "false")
s.setDefault(LastVersionKey, "")
s.setDefault(UpdateChannelKey, "")
s.setDefault(RolloutKey, fmt.Sprintf("%v", rand.Float64()))
s.setDefault(APIPortKey, DefaultAPIPort)

View File

@ -137,6 +137,7 @@ Dialog {
spacing: Style.dialog.spacing
ButtonRounded {
id:buttonNo
visible: root.state != "toggleEarlyAccess"
color_main: Style.dialog.text
fa_icon: Style.fa.times
text: qsTr("No")
@ -148,7 +149,7 @@ Dialog {
color_minor: Style.main.textBlue
isOpaque: true
fa_icon: Style.fa.check
text: qsTr("Yes")
text: root.state == "toggleEarlyAccess" ? qsTr("Ok") : qsTr("Yes")
onClicked : {
currentIndex=1
root.confirmed()
@ -292,6 +293,17 @@ Dialog {
}
}
},
State {
name: "toggleEarlyAccess"
PropertyChanges {
target: root
currentIndex : 0
question : qsTr("Do you want to be the first to get the latest updates? Please keep in mind that early versions may be less stable.")
note : ""
title : qsTr("Enable early access")
answer : qsTr("Enabling early access...")
}
},
State {
name: "noKeychain"
PropertyChanges {
@ -343,8 +355,6 @@ Dialog {
root.visible = true
}
onConfirmed : {
if (state == "quit" || state == "instance exists" ) {
timer.interval = 1000
@ -358,17 +368,18 @@ Dialog {
Connections {
target: timer
onTriggered: {
if ( state == "addressmode" ) { go.switchAddressMode (input) }
if ( state == "clearChain" ) { go.clearKeychain () }
if ( state == "clearCache" ) { go.clearCache () }
if ( state == "deleteUser" ) { go.deleteAccount (input, checkBoxWrapper.isChecked) }
if ( state == "logout" ) { go.logoutAccount (input) }
if ( state == "toggleAutoStart" ) { go.toggleAutoStart () }
if ( state == "toggleAllowProxy" ) { go.toggleAllowProxy () }
if ( state == "quit" ) { Qt.quit () }
if ( state == "instance exists" ) { Qt.quit () }
if ( state == "noKeychain" ) { Qt.quit () }
if ( state == "checkUpdates" ) { }
if ( state == "addressmode" ) { go.switchAddressMode (input) }
if ( state == "clearChain" ) { go.clearKeychain () }
if ( state == "clearCache" ) { go.clearCache () }
if ( state == "deleteUser" ) { go.deleteAccount (input, checkBoxWrapper.isChecked) }
if ( state == "logout" ) { go.logoutAccount (input) }
if ( state == "toggleAutoStart" ) { go.toggleAutoStart () }
if ( state == "toggleAllowProxy" ) { go.toggleAllowProxy () }
if ( state == "toggleEarlyAccess" ) { go.toggleEarlyAccess () }
if ( state == "quit" ) { Qt.quit () }
if ( state == "instance exists" ) { Qt.quit () }
if ( state == "noKeychain" ) { Qt.quit () }
if ( state == "checkUpdates" ) { }
}
}

View File

@ -116,6 +116,30 @@ Item {
}
}
ButtonIconText {
id: earlyAccess
text: qsTr("Early access", "label for toggle that enables and disables early access")
leftIcon.text : Style.fa.star
rightIcon {
font.pointSize : Style.settings.toggleSize * Style.pt
text : go.isEarlyAccess!=false ? Style.fa.toggle_on : Style.fa.toggle_off
color : go.isEarlyAccess!=false ? Style.main.textBlue : Style.main.textDisabled
}
Accessible.description: (
go.isEarlyAccess == false ?
qsTr("Enable" , "Click to enable early access") :
qsTr("Disable" , "Click to disable early access")
) + " " + text
onClicked: {
if (go.isEarlyAccess == true) {
go.toggleEarlyAccess()
} else {
dialogGlobal.state="toggleEarlyAccess"
dialogGlobal.show()
}
}
}
ButtonIconText {
id: advancedSettings
property bool isAdvanced : !go.isDefaultPort
@ -196,7 +220,6 @@ Item {
dialogGlobal.show()
}
}
}
}
}

View File

@ -267,6 +267,7 @@ Window {
property bool isAutoStart : true
property bool isAutoUpdate : false
property bool isEarlyAccess : false
property bool isProxyAllowed : false
property bool isFirstStart : false
property bool isFreshVersion : false
@ -336,6 +337,7 @@ Window {
signal processFinished()
signal toggleAutoStart()
signal toggleEarlyAccess()
signal toggleAutoUpdate()
signal notifyBubble(int tabIndex, string message)
signal silentBubble(int tabIndex, string message)
@ -627,6 +629,12 @@ Window {
isAutoUpdate = (isAutoUpdate!=false) ? false : true
console.log (" Test: onToggleAutoUpdate "+isAutoUpdate)
}
onToggleEarlyAccess: {
workAndClose()
isEarlyAccess = (isEarlyAccess!=false) ? false : true
console.log (" Test: onToggleEarlyAccess "+isEarlyAccess)
}
}
}

View File

@ -370,6 +370,12 @@ func (s *FrontendQt) qtExecute(Procedure func(*FrontendQt) error) error {
s.Qml.SetIsProxyAllowed(false)
}
if updater.UpdateChannel(s.settings.Get(settings.UpdateChannelKey)) == updater.BetaChannel {
s.Qml.SetIsEarlyAccess(true)
} else {
s.Qml.SetIsEarlyAccess(false)
}
s.eventListener.RetryEmit(events.TLSCertIssue)
s.eventListener.RetryEmit(events.ErrorEvent)
@ -548,6 +554,18 @@ func (s *FrontendQt) toggleAutoUpdate() {
}
}
func (s *FrontendQt) toggleEarlyAccess() {
defer s.Qml.ProcessFinished()
if updater.UpdateChannel(s.settings.Get(settings.UpdateChannelKey)) == updater.BetaChannel {
s.settings.Set(settings.UpdateChannelKey, string(updater.LiveChannel))
s.Qml.SetIsEarlyAccess(false)
} else {
s.settings.Set(settings.UpdateChannelKey, string(updater.BetaChannel))
s.Qml.SetIsEarlyAccess(true)
}
}
func (s *FrontendQt) toggleAllowProxy() {
defer s.Qml.ProcessFinished()

View File

@ -35,6 +35,7 @@ type GoQMLInterface struct {
_ bool `property:"isAutoStart"`
_ bool `property:"isAutoUpdate"`
_ bool `property:"isEarlyAccess"`
_ bool `property:"isProxyAllowed"`
_ string `property:"currentAddress"`
_ string `property:"goos"`
@ -94,6 +95,7 @@ type GoQMLInterface struct {
_ func() `slot:"toggleAutoStart"`
_ func() `slot:"toggleAutoUpdate"`
_ func() `slot:"toggleEarlyAccess"`
_ func() `slot:"toggleAllowProxy"`
_ func() `slot:"loadAccounts"`
_ func() `slot:"openLogs"`
@ -157,6 +159,7 @@ func (s *GoQMLInterface) init() {}
// SetFrontend connects all slots and signals from Go to QML.
func (s *GoQMLInterface) SetFrontend(f *FrontendQt) {
s.ConnectToggleAutoStart(f.toggleAutoStart)
s.ConnectToggleEarlyAccess(f.toggleEarlyAccess)
s.ConnectToggleAutoUpdate(f.toggleAutoUpdate)
s.ConnectToggleAllowProxy(f.toggleAllowProxy)
s.ConnectLoadAccounts(f.loadAccounts)

View File

@ -1,24 +0,0 @@
// Copyright (c) 2021 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.
//
// ProtonMail Bridge 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 3 of the License, or
// (at your option) any later version.
//
// ProtonMail Bridge 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build !beta
package updater
// Channel is the channel of updates users are subscribed to.
// For now it is hardcoded in the build. In future, it might be selectable in settings.
const Channel = "live"

View File

@ -15,8 +15,15 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build beta
package updater
const Channel = "beta"
// UpdateChannel represents an update channel users can be subscribed to.
type UpdateChannel string
const (
// LiveChannel is the channel all users are subscribed to by default.
LiveChannel UpdateChannel = "live"
// BetaChannel is the channel users subscribe to when they enable "Early Access".
BetaChannel UpdateChannel = "beta"
)

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build !pmapi_qa
// +build !build_qa
package updater

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build pmapi_qa
// +build build_qa
package updater

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build !pmapi_qa
// +build !build_qa
package updater

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// +build pmapi_qa
// +build build_qa
package updater

View File

@ -23,6 +23,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/internal/config/settings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@ -38,15 +39,21 @@ type Installer interface {
InstallUpdate(*semver.Version, io.Reader) error
}
type Settings interface {
Get(string) string
Set(string, string)
GetFloat64(string) float64
}
type Updater struct {
cm ClientProvider
installer Installer
settings Settings
kr *crypto.KeyRing
curVer *semver.Version
updateURLName string
platform string
rollout float64
locker *locker
}
@ -54,19 +61,25 @@ type Updater struct {
func New(
cm ClientProvider,
installer Installer,
s Settings,
kr *crypto.KeyRing,
curVer *semver.Version,
updateURLName, platform string,
rollout float64,
) *Updater {
// If there's some unexpected value in the preferences, we force it back onto the live channel.
// This prevents users from screwing up silent updates by modifying their prefs.json file.
if channel := UpdateChannel(s.Get(settings.UpdateChannelKey)); !(channel == LiveChannel || channel == BetaChannel) {
s.Set(settings.UpdateChannelKey, string(LiveChannel))
}
return &Updater{
cm: cm,
installer: installer,
settings: s,
kr: kr,
curVer: curVer,
updateURLName: updateURLName,
platform: platform,
rollout: rollout,
locker: newLocker(),
}
}
@ -92,7 +105,12 @@ func (u *Updater) Check() (VersionInfo, error) {
return VersionInfo{}, err
}
return versionMap[Channel], nil
version, ok := versionMap[u.settings.Get(settings.UpdateChannelKey)]
if !ok {
return VersionInfo{}, errors.New("no updates available for this channel")
}
return version, nil
}
func (u *Updater) IsUpdateApplicable(version VersionInfo) bool {
@ -100,7 +118,7 @@ func (u *Updater) IsUpdateApplicable(version VersionInfo) bool {
return false
}
if u.rollout > version.Rollout {
if u.settings.GetFloat64(settings.RolloutKey) > version.Rollout {
return false
}
@ -108,6 +126,10 @@ func (u *Updater) IsUpdateApplicable(version VersionInfo) bool {
}
func (u *Updater) CanInstall(version VersionInfo) bool {
if version.MinAuto == nil {
return true
}
return !u.curVer.LessThan(version.MinAuto)
}

View File

@ -22,11 +22,13 @@ import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"sync"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/internal/config/settings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks"
"github.com/golang/mock/gomock"
@ -40,7 +42,7 @@ func TestCheck(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.1.0")
updater := newTestUpdater(client, "1.1.0", false)
versionMap := VersionMap{
"live": VersionInfo{
@ -65,13 +67,50 @@ func TestCheck(t *testing.T) {
assert.NoError(t, err)
}
func TestCheckEarlyAccess(t *testing.T) {
c := gomock.NewController(t)
defer c.Finish()
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.1.0", true)
versionMap := VersionMap{
"live": VersionInfo{
Version: semver.MustParse("1.5.0"),
MinAuto: semver.MustParse("1.0.0"),
Package: "https://protonmail.com/download/bridge/update_1.5.0_linux.tgz",
Rollout: 1.0,
},
"beta": VersionInfo{
Version: semver.MustParse("1.6.0"),
MinAuto: semver.MustParse("1.0.0"),
Package: "https://protonmail.com/download/bridge/update_1.6.0_linux.tgz",
Rollout: 1.0,
},
}
client.EXPECT().DownloadAndVerify(
updater.getVersionFileURL(),
updater.getVersionFileURL()+".sig",
gomock.Any(),
).Return(bytes.NewReader(mustMarshal(t, versionMap)), nil)
client.EXPECT().Logout()
version, err := updater.Check()
assert.Equal(t, semver.MustParse("1.6.0"), version.Version)
assert.NoError(t, err)
}
func TestCheckBadSignature(t *testing.T) {
c := gomock.NewController(t)
defer c.Finish()
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.2.0")
updater := newTestUpdater(client, "1.2.0", false)
client.EXPECT().DownloadAndVerify(
updater.getVersionFileURL(),
@ -92,7 +131,7 @@ func TestIsUpdateApplicable(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.4.0")
updater := newTestUpdater(client, "1.4.0", false)
versionOld := VersionInfo{
Version: semver.MustParse("1.3.0"),
@ -128,7 +167,7 @@ func TestCanInstall(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.4.0")
updater := newTestUpdater(client, "1.4.0", false)
versionManual := VersionInfo{
Version: semver.MustParse("1.5.0"),
@ -155,7 +194,7 @@ func TestInstallUpdate(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.4.0")
updater := newTestUpdater(client, "1.4.0", false)
latestVersion := VersionInfo{
Version: semver.MustParse("1.5.0"),
@ -183,7 +222,7 @@ func TestInstallUpdateBadSignature(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.4.0")
updater := newTestUpdater(client, "1.4.0", false)
latestVersion := VersionInfo{
Version: semver.MustParse("1.5.0"),
@ -211,7 +250,7 @@ func TestInstallUpdateAlreadyOngoing(t *testing.T) {
client := mocks.NewMockClient(c)
updater := newTestUpdater(client, "1.4.0")
updater := newTestUpdater(client, "1.4.0", false)
updater.installer = &fakeInstaller{delay: 2 * time.Second}
@ -249,14 +288,14 @@ func TestInstallUpdateAlreadyOngoing(t *testing.T) {
wg.Wait()
}
func newTestUpdater(client *mocks.MockClient, curVer string) *Updater {
func newTestUpdater(client *mocks.MockClient, curVer string, earlyAccess bool) *Updater {
return New(
&fakeClientProvider{client: client},
&fakeInstaller{},
newFakeSettings(0.5, earlyAccess),
nil,
semver.MustParse(curVer),
"bridge", "linux",
0.5,
)
}
@ -289,3 +328,31 @@ func mustMarshal(t *testing.T, v interface{}) []byte {
return b
}
type fakeSettings struct {
*settings.Settings
dir string
}
// newFakeSettings creates a temporary folder for files.
func newFakeSettings(rollout float64, earlyAccess bool) *fakeSettings {
dir, err := ioutil.TempDir("", "test-settings")
if err != nil {
panic(err)
}
s := &fakeSettings{
Settings: settings.New(dir),
dir: dir,
}
s.SetFloat64(settings.RolloutKey, rollout)
if earlyAccess {
s.Set(settings.UpdateChannelKey, string(BetaChannel))
} else {
s.Set(settings.UpdateChannelKey, string(LiveChannel))
}
return s
}