chore: merge release/perth_narrows into devel

This commit is contained in:
Jakub
2023-03-13 11:39:04 +01:00
159 changed files with 8308 additions and 1899 deletions

View File

@ -81,7 +81,7 @@ const (
appUsage = "Proton Mail IMAP and SMTP Bridge"
)
func New() *cli.App { //nolint:funlen
func New() *cli.App {
app := cli.NewApp()
app.Name = constants.FullAppName
@ -156,7 +156,7 @@ func New() *cli.App { //nolint:funlen
return app
}
func run(c *cli.Context) error { //nolint:funlen
func run(c *cli.Context) error {
// Seed the default RNG from the math/rand package.
rand.Seed(time.Now().UnixNano())
@ -208,9 +208,14 @@ func run(c *cli.Context) error { //nolint:funlen
}
// Ensure we are the only instance running.
return withSingleInstance(locations, version, func() error {
settings, err := locations.ProvideSettingsPath()
if err != nil {
logrus.WithError(err).Error("Failed to get settings path")
}
return withSingleInstance(settings, locations.GetLockFile(), version, func() error {
// Unlock the encrypted vault.
return WithVault(locations, func(vault *vault.Vault, insecure, corrupt bool) error {
return WithVault(locations, func(v *vault.Vault, insecure, corrupt bool) error {
// Report insecure vault.
if insecure {
_ = reporter.ReportMessageWithContext("Vault is insecure", map[string]interface{}{})
@ -221,27 +226,27 @@ func run(c *cli.Context) error { //nolint:funlen
_ = reporter.ReportMessageWithContext("Vault is corrupt", map[string]interface{}{})
}
if !vault.Migrated() {
if !v.Migrated() {
// Migrate old settings into the vault.
if err := migrateOldSettings(vault); err != nil {
if err := migrateOldSettings(v); err != nil {
logrus.WithError(err).Error("Failed to migrate old settings")
}
// Migrate old accounts into the vault.
if err := migrateOldAccounts(locations, vault); err != nil {
if err := migrateOldAccounts(locations, v); err != nil {
logrus.WithError(err).Error("Failed to migrate old accounts")
}
// The vault has been migrated.
if err := vault.SetMigrated(); err != nil {
if err := v.SetMigrated(); err != nil {
logrus.WithError(err).Error("Failed to mark vault as migrated")
}
}
// Load the cookies from the vault.
return withCookieJar(vault, func(cookieJar http.CookieJar) error {
return withCookieJar(v, func(cookieJar http.CookieJar) error {
// Create a new bridge instance.
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, vault, cookieJar, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, func(b *bridge.Bridge, eventCh <-chan events.Event) error {
if insecure {
logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted")
b.PushError(bridge.ErrVaultInsecure)
@ -266,15 +271,15 @@ func run(c *cli.Context) error { //nolint:funlen
}
// If there's another instance already running, try to raise it and exit.
func withSingleInstance(locations *locations.Locations, version *semver.Version, fn func() error) error {
func withSingleInstance(settingPath, lockFile string, version *semver.Version, fn func() error) error {
logrus.Debug("Checking for other instances")
defer logrus.Debug("Single instance stopped")
lock, err := checkSingleInstance(locations.GetLockFile(), version)
lock, err := checkSingleInstance(settingPath, lockFile, version)
if err != nil {
logrus.Info("Another instance is already running; raising it")
if ok := focus.TryRaise(); !ok {
if ok := focus.TryRaise(settingPath); !ok {
return fmt.Errorf("another instance is already running but it could not be raised")
}

View File

@ -23,6 +23,7 @@ import (
"runtime"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/go-autostart"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
@ -40,13 +41,11 @@ import (
"github.com/urfave/cli/v2"
)
const vaultSecretName = "bridge-vault-key"
// deleteOldGoIMAPFiles Set with `-ldflags -X app.deleteOldGoIMAPFiles=true` to enable cleanup of old imap cache data.
var deleteOldGoIMAPFiles bool //nolint:gochecknoglobals
// withBridge creates creates and tears down the bridge.
func withBridge( //nolint:funlen
func withBridge(
c *cli.Context,
exe string,
locations *locations.Locations,
@ -110,6 +109,7 @@ func withBridge( //nolint:funlen
// Crash and report stuff
crashHandler,
reporter,
imap.DefaultEpochUIDValidityGenerator(),
// The logging stuff.
c.String(flagLogIMAP) == "client" || c.String(flagLogIMAP) == "all",

View File

@ -187,7 +187,6 @@ func migrateOldAccount(userID string, store *credentials.Store, v *vault.Vault)
return nil
}
// nolint:funlen
func migratePrefsToVault(vault *vault.Vault, b []byte) error {
var prefs struct {
IMAPPort int `json:"user_port_imap,,string"`
@ -265,14 +264,6 @@ func migratePrefsToVault(vault *vault.Vault, b []byte) error {
errs = multierror.Append(errs, fmt.Errorf("failed to migrate show all mail: %w", err))
}
if err := vault.SetSyncWorkers(prefs.FetchWorkers); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to migrate sync workers: %w", err))
}
if err := vault.SetSyncAttPool(prefs.AttachmentWorkers); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to migrate sync attachment pool: %w", err))
}
if err := vault.SetCookies([]byte(prefs.Cookies)); err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to migrate cookies: %w", err))
}

View File

@ -68,8 +68,6 @@ func TestMigratePrefsToVault(t *testing.T) {
require.True(t, vault.GetAutostart())
// Check that the other app settings have been migrated.
require.Equal(t, 16, vault.SyncWorkers())
require.Equal(t, 16, vault.SyncAttPool())
require.False(t, vault.GetProxyAllowed())
require.False(t, vault.GetShowAllMail())

View File

@ -34,7 +34,7 @@ import (
//
// For macOS and Linux when already running version is older than this instance
// it will kill old and continue with this new bridge (i.e. no error returned).
func checkSingleInstance(lockFilePath string, curVersion *semver.Version) (*os.File, error) {
func checkSingleInstance(settingPath, lockFilePath string, curVersion *semver.Version) (*os.File, error) {
if lock, err := singleinstance.CreateLockFile(lockFilePath); err == nil {
logrus.WithField("path", lockFilePath).Debug("Created lock file; no other instance is running")
return lock, nil
@ -44,7 +44,7 @@ func checkSingleInstance(lockFilePath string, curVersion *semver.Version) (*os.F
// We couldn't create the lock file, so another instance is probably running.
// Check if it's an older version of the app.
lastVersion, ok := focus.TryVersion()
lastVersion, ok := focus.TryVersion(settingPath)
if !ok {
return nil, fmt.Errorf("failed to determine version of running instance")
}

View File

@ -18,18 +18,15 @@
package app
import (
"encoding/base64"
"fmt"
"path"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
)
func WithVault(locations *locations.Locations, fn func(*vault.Vault, bool, bool) error) error {
@ -51,7 +48,9 @@ func WithVault(locations *locations.Locations, fn func(*vault.Vault, bool, bool)
if installed := encVault.GetCertsInstalled(); !installed {
logrus.Debug("Installing certificates")
if err := certs.NewInstaller().InstallCert(encVault.GetBridgeTLSCert()); err != nil {
certPEM, _ := encVault.GetBridgeTLSCert()
if err := certs.NewInstaller().InstallCert(certPEM); err != nil {
return fmt.Errorf("failed to install certs: %w", err)
}
@ -80,7 +79,7 @@ func newVault(locations *locations.Locations) (*vault.Vault, bool, bool, error)
insecure bool
)
if key, err := getVaultKey(vaultDir); err != nil {
if key, err := loadVaultKey(vaultDir); err != nil {
insecure = true
// We store the insecure vault in a separate directory
@ -102,42 +101,25 @@ func newVault(locations *locations.Locations) (*vault.Vault, bool, bool, error)
return vault, insecure, corrupt, nil
}
func getVaultKey(vaultDir string) ([]byte, error) {
func loadVaultKey(vaultDir string) ([]byte, error) {
helper, err := vault.GetHelper(vaultDir)
if err != nil {
return nil, fmt.Errorf("could not get keychain helper: %w", err)
}
keychain, err := keychain.NewKeychain(helper, constants.KeyChainName)
kc, err := keychain.NewKeychain(helper, constants.KeyChainName)
if err != nil {
return nil, fmt.Errorf("could not create keychain: %w", err)
}
secrets, err := keychain.List()
has, err := vault.HasVaultKey(kc)
if err != nil {
return nil, fmt.Errorf("could not list keychain: %w", err)
return nil, fmt.Errorf("could not check for vault key: %w", err)
}
if !slices.Contains(secrets, vaultSecretName) {
tok, err := crypto.RandomToken(32)
if err != nil {
return nil, fmt.Errorf("could not generate random token: %w", err)
}
if err := keychain.Put(vaultSecretName, base64.StdEncoding.EncodeToString(tok)); err != nil {
return nil, fmt.Errorf("could not put keychain item: %w", err)
}
if has {
return vault.GetVaultKey(kc)
}
_, keyEnc, err := keychain.Get(vaultSecretName)
if err != nil {
return nil, fmt.Errorf("could not get keychain item: %w", err)
}
keyDec, err := base64.StdEncoding.DecodeString(keyEnc)
if err != nil {
return nil, fmt.Errorf("could not decode keychain item: %w", err)
}
return keyDec, nil
return vault.NewVaultKey(kc)
}

View File

@ -32,14 +32,12 @@ func defaultAPIOptions(
version *semver.Version,
cookieJar http.CookieJar,
transport http.RoundTripper,
poolSize int,
) []proton.Option {
return []proton.Option{
proton.WithHostURL(apiURL),
proton.WithAppVersion(constants.AppVersion(version.Original())),
proton.WithCookieJar(cookieJar),
proton.WithTransport(transport),
proton.WithAttPoolSize(poolSize),
proton.WithLogger(logrus.StandardLogger()),
}
}

View File

@ -32,7 +32,6 @@ func newAPIOptions(
version *semver.Version,
cookieJar http.CookieJar,
transport http.RoundTripper,
poolSize int,
) []proton.Option {
return defaultAPIOptions(apiURL, version, cookieJar, transport, poolSize)
return defaultAPIOptions(apiURL, version, cookieJar, transport)
}

View File

@ -33,9 +33,8 @@ func newAPIOptions(
version *semver.Version,
cookieJar http.CookieJar,
transport http.RoundTripper,
poolSize int,
) []proton.Option {
opt := defaultAPIOptions(apiURL, version, cookieJar, transport, poolSize)
opt := defaultAPIOptions(apiURL, version, cookieJar, transport)
if host := os.Getenv("BRIDGE_API_HOST"); host != "" {
opt = append(opt, proton.WithHostURL(host))

View File

@ -31,6 +31,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon"
imapEvents "github.com/ProtonMail/gluon/events"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/watcher"
"github.com/ProtonMail/go-proton-api"
@ -124,10 +125,12 @@ type Bridge struct {
// goUpdate triggers a check/install of updates.
goUpdate func()
uidValidityGenerator imap.UIDValidityGenerator
}
// New creates a new bridge.
func New( //nolint:funlen
func New(
locator Locator, // the locator to provide paths to store data
vault *vault.Vault, // the bridge's encrypted data store
autostarter Autostarter, // the autostarter to manage autostart settings
@ -142,12 +145,13 @@ func New( //nolint:funlen
proxyCtl ProxyController, // the DoH controller
crashHandler async.PanicHandler,
reporter reporter.Reporter,
uidValidityGenerator imap.UIDValidityGenerator,
logIMAPClient, logIMAPServer bool, // whether to log IMAP client/server activity
logSMTP bool, // whether to log SMTP activity
) (*Bridge, <-chan events.Event, error) {
// api is the user's API manager.
api := proton.New(newAPIOptions(apiURL, curVersion, cookieJar, roundTripper, vault.SyncAttPool())...)
api := proton.New(newAPIOptions(apiURL, curVersion, cookieJar, roundTripper)...)
// tasks holds all the bridge's background tasks.
tasks := async.NewGroup(context.Background(), crashHandler)
@ -171,6 +175,7 @@ func New( //nolint:funlen
api,
identifier,
proxyCtl,
uidValidityGenerator,
logIMAPClient, logIMAPServer, logSMTP,
)
if err != nil {
@ -185,22 +190,9 @@ func New( //nolint:funlen
return nil, nil, fmt.Errorf("failed to initialize bridge: %w", err)
}
// Start serving IMAP.
if err := bridge.serveIMAP(); err != nil {
logrus.WithError(err).Error("IMAP error")
bridge.PushError(ErrServeIMAP)
}
// Start serving SMTP.
if err := bridge.serveSMTP(); err != nil {
logrus.WithError(err).Error("SMTP error")
bridge.PushError(ErrServeSMTP)
}
return bridge, eventCh, nil
}
// nolint:funlen
func newBridge(
tasks *async.Group,
imapEventCh chan imapEvents.Event,
@ -216,6 +208,7 @@ func newBridge(
api *proton.Manager,
identifier Identifier,
proxyCtl ProxyController,
uidValidityGenerator imap.UIDValidityGenerator,
logIMAPClient, logIMAPServer, logSMTP bool,
) (*Bridge, error) {
@ -254,12 +247,13 @@ func newBridge(
logIMAPServer,
imapEventCh,
tasks,
uidValidityGenerator,
)
if err != nil {
return nil, fmt.Errorf("failed to create IMAP server: %w", err)
}
focusService, err := focus.NewService(curVersion)
focusService, err := focus.NewService(locator, curVersion)
if err != nil {
return nil, fmt.Errorf("failed to create focus service: %w", err)
}
@ -300,6 +294,8 @@ func newBridge(
lastVersion: lastVersion,
tasks: tasks,
uidValidityGenerator: uidValidityGenerator,
}
bridge.smtpServer = newSMTPServer(bridge, tlsConfig, logSMTP)
@ -307,7 +303,6 @@ func newBridge(
return bridge, nil
}
// nolint:funlen
func (bridge *Bridge) init(tlsReporter TLSReporter) error {
// Enable or disable the proxy at startup.
if bridge.vault.GetProxyAllowed() {
@ -376,16 +371,32 @@ func (bridge *Bridge) init(tlsReporter TLSReporter) error {
})
})
// Attempt to lazy load users when triggered.
// We need to load users before we can start the IMAP and SMTP servers.
// We must only start the servers once.
var once sync.Once
// Attempt to load users from the vault when triggered.
bridge.goLoad = bridge.tasks.Trigger(func(ctx context.Context) {
if err := bridge.loadUsers(ctx); err != nil {
logrus.WithError(err).Error("Failed to load users")
if netErr := new(proton.NetError); !errors.As(err, &netErr) {
sentry.ReportError(bridge.reporter, "Failed to load users", err)
}
} else {
bridge.publish(events.AllUsersLoaded{})
return
}
bridge.publish(events.AllUsersLoaded{})
// Once all users have been loaded, start the bridge's IMAP and SMTP servers.
once.Do(func() {
if err := bridge.serveIMAP(); err != nil {
logrus.WithError(err).Error("Failed to start IMAP server")
}
if err := bridge.serveSMTP(); err != nil {
logrus.WithError(err).Error("Failed to start SMTP server")
}
})
})
defer bridge.goLoad()
@ -545,7 +556,7 @@ func (bridge *Bridge) onStatusDown(ctx context.Context) {
}
func loadTLSConfig(vault *vault.Vault) (*tls.Config, error) {
cert, err := tls.X509KeyPair(vault.GetBridgeTLSCert(), vault.GetBridgeTLSKey())
cert, err := tls.X509KeyPair(vault.GetBridgeTLSCert())
if err != nil {
return nil, err
}

View File

@ -21,6 +21,7 @@ import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@ -29,6 +30,7 @@ import (
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/go-proton-api/server/backend"
@ -121,8 +123,11 @@ func TestBridge_Focus(t *testing.T) {
raiseCh, done := bridge.GetEvents(events.Raise{})
defer done()
settingsFolder, err := locator.ProvideSettingsPath()
require.NoError(t, err)
// Simulate a focus event.
focus.TryRaise()
focus.TryRaise(settingsFolder)
// Wait for the event.
require.IsType(t, events.Raise{}, <-raiseCh)
@ -496,6 +501,21 @@ func TestBridge_InitGluonDirectory(t *testing.T) {
})
}
func TestBridge_LoginFailed(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{}))
defer done()
imapClient, err := client.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
require.Error(t, imapClient.Login("badUser", "badPass"))
require.Equal(t, "badUser", (<-failCh).Username)
})
})
}
func TestBridge_ChangeCacheDirectory(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
userID, addrID, err := s.CreateUser("imap", password)
@ -657,6 +677,9 @@ func withMocks(t *testing.T, tests func(*bridge.Mocks)) {
tests(mocks)
}
// Needs to be global to survive bridge shutdown/startup in unit tests as they happen to fast.
var testUIDValidityGenerator = imap.DefaultEpochUIDValidityGenerator()
// withBridge creates a new bridge which points to the given API URL and uses the given keychain, and closes it when done.
func withBridgeNoMocks(
ctx context.Context,
@ -702,6 +725,7 @@ func withBridgeNoMocks(
mocks.ProxyCtl,
mocks.CrashHandler,
mocks.Reporter,
testUIDValidityGenerator,
// The logging stuff.
os.Getenv("BRIDGE_LOG_IMAP_CLIENT") == "1",
@ -713,6 +737,10 @@ func withBridgeNoMocks(
// Wait for bridge to finish loading users.
waitForEvent(t, eventCh, events.AllUsersLoaded{})
// Wait for bridge to start the IMAP server.
waitForEvent(t, eventCh, events.IMAPServerReady{})
// Wait for bridge to start the SMTP server.
waitForEvent(t, eventCh, events.SMTPServerReady{})
// Set random IMAP and SMTP ports for the tests.
require.NoError(t, bridge.SetIMAPPort(0))
@ -742,7 +770,7 @@ func withBridge(
})
}
func waitForEvent[T any](t *testing.T, eventCh <-chan events.Event, wantEvent T) {
func waitForEvent[T any](t *testing.T, eventCh <-chan events.Event, _ T) {
t.Helper()
for event := range eventCh {

View File

@ -37,7 +37,7 @@ const (
MaxCompressedFilesCount = 6
)
func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, description, username, email, client string, attachLogs bool) error { //nolint:funlen
func (bridge *Bridge) ReportBug(ctx context.Context, osType, osVersion, description, username, email, client string, attachLogs bool) error {
var account string
if info, err := bridge.QueryUserInfo(username); err == nil {

View File

@ -22,10 +22,7 @@ import "errors"
var (
ErrVaultInsecure = errors.New("the vault is insecure")
ErrVaultCorrupt = errors.New("the vault is corrupt")
ErrServeIMAP = errors.New("failed to serve IMAP")
ErrServeSMTP = errors.New("failed to serve SMTP")
ErrWatchUpdates = errors.New("failed to watch for updates")
ErrWatchUpdates = errors.New("failed to watch for updates")
ErrNoSuchUser = errors.New("no such user")
ErrUserAlreadyExists = errors.New("user already exists")

View File

@ -28,10 +28,13 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon"
imapEvents "github.com/ProtonMail/gluon/events"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/store"
"github.com/ProtonMail/gluon/store/fallback_v0"
"github.com/ProtonMail/proton-bridge/v3/internal/async"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
@ -44,26 +47,42 @@ const (
)
func (bridge *Bridge) serveIMAP() error {
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
port, err := func() (int, error) {
if bridge.imapServer == nil {
return 0, fmt.Errorf("no IMAP server instance running")
}
logrus.Info("Starting IMAP server")
logrus.Info("Starting IMAP server")
imapListener, err := newListener(bridge.vault.GetIMAPPort(), bridge.vault.GetIMAPSSL(), bridge.tlsConfig)
if err != nil {
return 0, fmt.Errorf("failed to create IMAP listener: %w", err)
}
bridge.imapListener = imapListener
if err := bridge.imapServer.Serve(context.Background(), bridge.imapListener); err != nil {
return 0, fmt.Errorf("failed to serve IMAP: %w", err)
}
if err := bridge.vault.SetIMAPPort(getPort(imapListener.Addr())); err != nil {
return 0, fmt.Errorf("failed to store IMAP port in vault: %w", err)
}
return getPort(imapListener.Addr()), nil
}()
imapListener, err := newListener(bridge.vault.GetIMAPPort(), bridge.vault.GetIMAPSSL(), bridge.tlsConfig)
if err != nil {
return fmt.Errorf("failed to create IMAP listener: %w", err)
bridge.publish(events.IMAPServerError{
Error: err,
})
return err
}
bridge.imapListener = imapListener
if err := bridge.imapServer.Serve(context.Background(), bridge.imapListener); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
if err := bridge.vault.SetIMAPPort(getPort(imapListener.Addr())); err != nil {
return fmt.Errorf("failed to store IMAP port in vault: %w", err)
}
bridge.publish(events.IMAPServerReady{
Port: port,
})
return nil
}
@ -75,6 +94,8 @@ func (bridge *Bridge) restartIMAP() error {
if err := bridge.imapListener.Close(); err != nil {
return fmt.Errorf("failed to close IMAP listener: %w", err)
}
bridge.publish(events.IMAPServerStopped{})
}
return bridge.serveIMAP()
@ -87,6 +108,7 @@ func (bridge *Bridge) closeIMAP(ctx context.Context) error {
if err := bridge.imapServer.Close(ctx); err != nil {
return fmt.Errorf("failed to close IMAP server: %w", err)
}
bridge.imapServer = nil
}
@ -96,12 +118,12 @@ func (bridge *Bridge) closeIMAP(ctx context.Context) error {
}
}
bridge.publish(events.IMAPServerStopped{})
return nil
}
// addIMAPUser connects the given user to gluon.
//
//nolint:funlen
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
@ -242,6 +264,13 @@ func (bridge *Bridge) handleIMAPEvent(event imapEvents.Event) {
if event.IMAPID.Name != "" && event.IMAPID.Version != "" {
bridge.identifier.SetClient(event.IMAPID.Name, event.IMAPID.Version)
}
case imapEvents.LoginFailed:
logrus.WithFields(logrus.Fields{
"sessionID": event.SessionID,
"username": event.Username,
}).Info("Received IMAP login failure notification")
bridge.publish(events.IMAPLoginFailed{Username: event.Username})
}
}
@ -261,7 +290,6 @@ func ApplyGluonConfigPathSuffix(basePath string) string {
return filepath.Join(basePath, "backend", "db")
}
// nolint:funlen
func newIMAPServer(
gluonCacheDir, gluonConfigDir string,
version *semver.Version,
@ -270,6 +298,7 @@ func newIMAPServer(
logClient, logServer bool,
eventCh chan<- imapEvents.Event,
tasks *async.Group,
uidValidityGenerator imap.UIDValidityGenerator,
) (*gluon.Server, error) {
gluonCacheDir = ApplyGluonCachePathSuffix(gluonCacheDir)
gluonConfigDir = ApplyGluonConfigPathSuffix(gluonConfigDir)
@ -313,6 +342,7 @@ func newIMAPServer(
gluon.WithLogger(imapClientLog, imapServerLog),
getGluonVersionInfo(version),
gluon.WithReporter(reporter),
gluon.WithUIDValidityGenerator(uidValidityGenerator),
)
if err != nil {
return nil, err
@ -348,7 +378,7 @@ func (*storeBuilder) New(path, userID string, passphrase []byte) (store.Store, e
return store.NewOnDiskStore(
filepath.Join(path, userID),
passphrase,
store.WithCompressor(new(store.GZipCompressor)),
store.WithFallback(fallback_v0.NewOnDiskStoreV0WithCompressor(&fallback_v0.GZipCompressor{})),
)
}

View File

@ -57,6 +57,7 @@ func TestBridge_Refresh(t *testing.T) {
require.Equal(t, userID, (<-syncCh).UserID)
})
var uidValidities = make(map[string]uint32, len(names))
// If we then connect an IMAP client, it should see all the labels with UID validity of 1.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
mocks.Reporter.EXPECT().ReportMessageWithContext(gomock.Any(), gomock.Any()).AnyTimes()
@ -73,7 +74,7 @@ func TestBridge_Refresh(t *testing.T) {
for _, name := range names {
status, err := client.Select("Folders/"+name, false)
require.NoError(t, err)
require.Equal(t, uint32(1000), status.UidValidity)
uidValidities[name] = status.UidValidity
}
})
@ -106,7 +107,7 @@ func TestBridge_Refresh(t *testing.T) {
for _, name := range names {
status, err := client.Select("Folders/"+name, false)
require.NoError(t, err)
require.Equal(t, uint32(1001), status.UidValidity)
require.Greater(t, status.UidValidity, uidValidities[name])
}
})
})

View File

@ -25,11 +25,9 @@ import (
"path/filepath"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
)
@ -131,26 +129,21 @@ func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error
return fmt.Errorf("new gluon dir is the same as the old one")
}
if err := bridge.stopEventLoops(); err != nil {
return err
if err := bridge.closeIMAP(context.Background()); err != nil {
return fmt.Errorf("failed to close IMAP: %w", err)
}
defer func() {
err := bridge.startEventLoops(ctx)
if err != nil {
panic(err)
}
}()
if err := bridge.moveGluonCacheDir(currentGluonDir, newGluonDir); err != nil {
logrus.WithError(err).Error("failed to move GluonCacheDir")
if err := bridge.vault.SetGluonDir(currentGluonDir); err != nil {
panic(err)
return fmt.Errorf("failed to revert GluonCacheDir: %w", err)
}
}
gluonDataDir, err := bridge.GetGluonDataDir()
if err != nil {
panic(fmt.Errorf("failed to get Gluon Database directory: %w", err))
return fmt.Errorf("failed to get Gluon Database directory: %w", err)
}
imapServer, err := newIMAPServer(
@ -163,13 +156,24 @@ func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error
bridge.logIMAPServer,
bridge.imapEventCh,
bridge.tasks,
bridge.uidValidityGenerator,
)
if err != nil {
panic(fmt.Errorf("failed to create new IMAP server: %w", err))
return fmt.Errorf("failed to create new IMAP server: %w", err)
}
bridge.imapServer = imapServer
for _, user := range bridge.users {
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add users to new IMAP server: %w", err)
}
}
if err := bridge.serveIMAP(); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
return nil
}, bridge.usersLock)
}
@ -191,34 +195,6 @@ func (bridge *Bridge) moveGluonCacheDir(oldGluonDir, newGluonDir string) error {
return nil
}
func (bridge *Bridge) stopEventLoops() error {
if err := bridge.closeIMAP(context.Background()); err != nil {
return fmt.Errorf("failed to close IMAP: %w", err)
}
if err := bridge.closeSMTP(); err != nil {
return fmt.Errorf("failed to close SMTP: %w", err)
}
return nil
}
func (bridge *Bridge) startEventLoops(ctx context.Context) error {
for _, user := range bridge.users {
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add users to new IMAP server: %w", err)
}
}
if err := bridge.serveIMAP(); err != nil {
panic(fmt.Errorf("failed to serve IMAP: %w", err))
}
if err := bridge.serveSMTP(); err != nil {
panic(fmt.Errorf("failed to serve SMTP: %w", err))
}
return nil
}
func (bridge *Bridge) GetProxyAllowed() bool {
return bridge.vault.GetProxyAllowed()
}
@ -332,6 +308,9 @@ func (bridge *Bridge) SetColorScheme(colorScheme string) error {
return bridge.vault.SetColorScheme(colorScheme)
}
// FactoryReset deletes all users, wipes the vault, and deletes all files.
// Note: it does not clear the keychain. The only entry in the keychain is the vault password,
// which we need at next startup to decrypt the vault.
func (bridge *Bridge) FactoryReset(ctx context.Context) {
// Delete all the users.
safe.Lock(func() {
@ -348,22 +327,10 @@ func (bridge *Bridge) FactoryReset(ctx context.Context) {
logrus.WithError(err).Error("Failed to reset vault")
}
// Then delete all files.
if err := bridge.locator.Clear(); err != nil {
// Lastly, delete all files except the vault.
if err := bridge.locator.Clear(bridge.vault.Path()); err != nil {
logrus.WithError(err).Error("Failed to clear data paths")
}
// Lastly clear the keychain.
vaultDir, err := bridge.locator.ProvideSettingsPath()
if err != nil {
logrus.WithError(err).Error("Failed to get vault dir")
} else if helper, err := vault.GetHelper(vaultDir); err != nil {
logrus.WithError(err).Error("Failed to get keychain helper")
} else if keychain, err := keychain.NewKeychain(helper, constants.KeyChainName); err != nil {
logrus.WithError(err).Error("Failed to get keychain")
} else if err := keychain.Clear(); err != nil {
logrus.WithError(err).Error("Failed to clear keychain")
}
}
func getPort(addr net.Addr) int {

View File

@ -22,6 +22,7 @@ import (
"crypto/tls"
"fmt"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
@ -31,25 +32,41 @@ import (
)
func (bridge *Bridge) serveSMTP() error {
logrus.Info("Starting SMTP server")
port, err := func() (int, error) {
logrus.Info("Starting SMTP server")
smtpListener, err := newListener(bridge.vault.GetSMTPPort(), bridge.vault.GetSMTPSSL(), bridge.tlsConfig)
if err != nil {
return fmt.Errorf("failed to create SMTP listener: %w", err)
}
bridge.smtpListener = smtpListener
bridge.tasks.Once(func(context.Context) {
if err := bridge.smtpServer.Serve(smtpListener); err != nil {
logrus.WithError(err).Info("SMTP server stopped")
smtpListener, err := newListener(bridge.vault.GetSMTPPort(), bridge.vault.GetSMTPSSL(), bridge.tlsConfig)
if err != nil {
return 0, fmt.Errorf("failed to create SMTP listener: %w", err)
}
})
if err := bridge.vault.SetSMTPPort(getPort(smtpListener.Addr())); err != nil {
return fmt.Errorf("failed to store SMTP port in vault: %w", err)
bridge.smtpListener = smtpListener
bridge.tasks.Once(func(context.Context) {
if err := bridge.smtpServer.Serve(smtpListener); err != nil {
logrus.WithError(err).Info("SMTP server stopped")
}
})
if err := bridge.vault.SetSMTPPort(getPort(smtpListener.Addr())); err != nil {
return 0, fmt.Errorf("failed to store SMTP port in vault: %w", err)
}
return getPort(smtpListener.Addr()), nil
}()
if err != nil {
bridge.publish(events.SMTPServerError{
Error: err,
})
return err
}
bridge.publish(events.SMTPServerReady{
Port: port,
})
return nil
}
@ -60,6 +77,8 @@ func (bridge *Bridge) restartSMTP() error {
return fmt.Errorf("failed to close SMTP: %w", err)
}
bridge.publish(events.SMTPServerStopped{})
bridge.smtpServer = newSMTPServer(bridge, bridge.tlsConfig, bridge.logSMTP)
return bridge.serveSMTP()
@ -82,6 +101,8 @@ func (bridge *Bridge) closeSMTP() error {
logrus.WithError(err).Debug("Failed to close SMTP server (expected -- we close the listener ourselves)")
}
bridge.publish(events.SMTPServerStopped{})
return nil
}

View File

@ -431,7 +431,7 @@ func createMessages(ctx context.Context, t *testing.T, c *proton.Client, addrID,
_, ok := addrKRs[addrID]
require.True(t, ok)
res, err := stream.Collect(ctx, c.ImportMessages(
str, err := c.ImportMessages(
ctx,
addrKRs[addrID],
runtime.NumCPU(),
@ -446,7 +446,10 @@ func createMessages(ctx context.Context, t *testing.T, c *proton.Client, addrID,
Message: message,
}
})...,
))
)
require.NoError(t, err)
res, err := stream.Collect(ctx, str)
require.NoError(t, err)
return xslices.Map(res, func(res proton.ImportRes) string {

View File

@ -18,5 +18,9 @@
package bridge
func (bridge *Bridge) GetBridgeTLSCert() ([]byte, []byte) {
return bridge.vault.GetBridgeTLSCert(), bridge.vault.GetBridgeTLSKey()
return bridge.vault.GetBridgeTLSCert()
}
func (bridge *Bridge) SetBridgeTLSCertPath(certPath, keyPath string) error {
return bridge.vault.SetBridgeTLSCertPath(certPath, keyPath)
}

View File

@ -30,7 +30,7 @@ type Locator interface {
ProvideGluonDataPath() (string, error)
GetLicenseFilePath() string
GetDependencyLicensesLink() string
Clear() error
Clear(...string) error
}
type Identifier interface {

View File

@ -32,19 +32,7 @@ func (bridge *Bridge) CheckForUpdates() {
}
func (bridge *Bridge) InstallUpdate(version updater.VersionInfo) {
log := logrus.WithFields(logrus.Fields{
"version": version.Version,
"current": bridge.curVersion,
"channel": bridge.vault.GetUpdateChannel(),
})
select {
case bridge.installCh <- installJob{version: version, silent: false}:
log.Info("The update will be installed manually")
default:
log.Info("An update is already being installed")
}
bridge.installCh <- installJob{version: version, silent: false}
}
func (bridge *Bridge) handleUpdate(version updater.VersionInfo) {
@ -89,17 +77,7 @@ func (bridge *Bridge) handleUpdate(version updater.VersionInfo) {
default:
safe.RLock(func() {
if version.Version.GreaterThan(bridge.newVersion) {
log.Info("An update is available")
select {
case bridge.installCh <- installJob{version: version, silent: true}:
log.Info("The update will be installed silently")
default:
log.Info("An update is already being installed")
}
}
bridge.installCh <- installJob{version: version, silent: true}
}, bridge.newVersionLock)
}
}
@ -117,6 +95,12 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) {
"channel": bridge.vault.GetUpdateChannel(),
})
if !job.version.Version.GreaterThan(bridge.newVersion) {
return
}
log.WithField("silent", job.silent).Info("An update is available")
bridge.publish(events.UpdateAvailable{
Version: job.version,
Compatible: true,
@ -142,6 +126,7 @@ func (bridge *Bridge) installUpdate(ctx context.Context, job installJob) {
Silent: job.silent,
Error: err,
})
default:
log.Info("The update was installed successfully")

View File

@ -434,6 +434,7 @@ func (bridge *Bridge) loadUser(ctx context.Context, user *vault.User) error {
logrus.WithError(err).Warn("Failed to clear user secrets")
}
}
return fmt.Errorf("failed to create API client: %w", err)
}
@ -516,8 +517,8 @@ func (bridge *Bridge) addUserWithVault(
bridge.reporter,
apiUser,
bridge.crashHandler,
bridge.vault.SyncWorkers(),
bridge.vault.GetShowAllMail(),
bridge.vault.GetMaxSyncMemory(),
)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)

View File

@ -20,18 +20,23 @@ package bridge_test
import (
"context"
"fmt"
"net"
"net/http"
"net/mail"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/go-proton-api/server"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/user"
"github.com/bradenaw/juniper/stream"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
@ -192,12 +197,13 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
var messageIDs []string
// Create 10 more messages for the user, generating events.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
messageIDs = createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
})
// If bridge attempts to sync the new messages, it should get a BadRequest error.
s.AddStatusHook(func(req *http.Request) (int, bool) {
if len(messageIDs) < 3 {
return 0, false
}
if strings.Contains(req.URL.Path, "/mail/v4/messages/"+messageIDs[2]) {
return http.StatusUnprocessableEntity, true
}
@ -205,11 +211,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
return 0, false
})
// Create 10 more messages for the user, generating events.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
messageIDs = createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
})
// Remove messages
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
require.NoError(t, c.DeleteMessage(ctx, messageIDs...))
@ -374,6 +375,396 @@ func TestBridge_User_Network_NoBadEvents(t *testing.T) {
})
}
func TestBridge_User_DropConn_NoBadEvent(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
dropListener := proton.NewListener(l, proton.NewDropConn)
defer func() { _ = dropListener.Close() }()
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a user.
_, addrID, err := s.CreateUser("user", password)
require.NoError(t, err)
// Create 10 messages for the user.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password)
mocks.Reporter.EXPECT().ReportMessageWithContext(gomock.Any(), gomock.Any()).AnyTimes()
// Create 10 more messages for the user, generating events.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
createNumMessages(ctx, t, c, addrID, proton.InboxLabel, 10)
})
var count int
// The first 10 times bridge attempts to sync any of the messages, drop the connection.
s.AddStatusHook(func(req *http.Request) (int, bool) {
if strings.Contains(req.URL.Path, "/mail/v4/messages") {
if count++; count < 10 {
dropListener.DropAll()
}
}
return 0, false
})
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
// The IMAP client will eventually see 20 messages.
require.Eventually(t, func() bool {
status, err := client.Status("INBOX", []imap.StatusItem{imap.StatusMessages})
return err == nil && status.Messages == 20
}, 10*time.Second, 100*time.Millisecond)
})
}, server.WithListener(dropListener))
}
func TestBridge_User_UpdateDraftAndCreateOtherMessage(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a bridge user.
_, _, err := s.CreateUser("user", password)
require.NoError(t, err)
// Initially sync the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password)
})
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
user, err := c.GetUser(ctx)
require.NoError(t, err)
addrs, err := c.GetAddresses(ctx)
require.NoError(t, err)
salts, err := c.GetSalts(ctx)
require.NoError(t, err)
keyPass, err := salts.SaltForKey(password, user.Keys.Primary().ID)
require.NoError(t, err)
_, addrKRs, err := proton.Unlock(user, addrs, keyPass)
require.NoError(t, err)
// Create a draft (generating a "create draft message" event).
draft, err := c.CreateDraft(ctx, addrKRs[addrs[0].ID], proton.CreateDraftReq{
Message: proton.DraftTemplate{
Subject: "subject",
Sender: &mail.Address{Name: "sender", Address: addrs[0].Email},
Body: "body",
MIMEType: rfc822.TextPlain,
},
})
require.NoError(t, err)
// Process those events
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge)
})
// Update the draft (generating an "update draft message" event).
require.NoError(t, getErr(c.UpdateDraft(ctx, draft.ID, addrKRs[addrs[0].ID], proton.UpdateDraftReq{
Message: proton.DraftTemplate{
Subject: "subject 2",
Sender: &mail.Address{Name: "sender", Address: addrs[0].Email},
Body: "body 2",
MIMEType: rfc822.TextPlain,
},
})))
// Import a message (generating a "create message" event).
str, err := c.ImportMessages(ctx, addrKRs[addrs[0].ID], 1, 1, proton.ImportReq{
Metadata: proton.ImportMetadata{
AddressID: addrs[0].ID,
Flags: proton.MessageFlagReceived,
},
Message: []byte("From: someone@example.com\r\nTo: blabla@example.com\r\n\r\nhello"),
})
require.NoError(t, err)
res, err := stream.Collect(ctx, str)
require.NoError(t, err)
// Process those events.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge)
})
// Update the imported message (generating an "update message" event).
require.NoError(t, c.MarkMessagesUnread(ctx, res[0].MessageID))
// Process those events.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge)
})
})
})
}
func TestBridge_User_SendDraftRemoveDraftFlag(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a bridge user.
_, _, err := s.CreateUser("user", password)
require.NoError(t, err)
// Initially sync the user.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userLoginAndSync(ctx, t, bridge, "user", password)
})
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
user, err := c.GetUser(ctx)
require.NoError(t, err)
addrs, err := c.GetAddresses(ctx)
require.NoError(t, err)
salts, err := c.GetSalts(ctx)
require.NoError(t, err)
keyPass, err := salts.SaltForKey(password, user.Keys.Primary().ID)
require.NoError(t, err)
_, addrKRs, err := proton.Unlock(user, addrs, keyPass)
require.NoError(t, err)
// Create a draft (generating a "create draft message" event).
draft, err := c.CreateDraft(ctx, addrKRs[addrs[0].ID], proton.CreateDraftReq{
Message: proton.DraftTemplate{
Subject: "subject",
ToList: []*mail.Address{{Address: addrs[0].Email}},
Sender: &mail.Address{Name: "sender", Address: addrs[0].Email},
Body: "body",
MIMEType: rfc822.TextPlain,
},
})
require.NoError(t, err)
// Process those events
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge)
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
messages, err := clientFetch(client, "Drafts")
require.NoError(t, err)
require.Len(t, messages, 1)
require.Contains(t, messages[0].Flags, imap.DraftFlag)
})
// Send the draft (generating an "update message" event).
{
pubKeys, recType, err := c.GetPublicKeys(ctx, addrs[0].Email)
require.NoError(t, err)
require.Equal(t, recType, proton.RecipientTypeInternal)
var req proton.SendDraftReq
require.NoError(t, req.AddTextPackage(addrKRs[addrs[0].ID], "body", rfc822.TextPlain, map[string]proton.SendPreferences{
addrs[0].Email: {
Encrypt: true,
PubKey: must(crypto.NewKeyRing(must(crypto.NewKeyFromArmored(pubKeys[0].PublicKey)))),
SignatureType: proton.DetachedSignature,
EncryptionScheme: proton.InternalScheme,
MIMEType: rfc822.TextPlain,
},
}, nil))
require.NoError(t, getErr(c.SendDraft(ctx, draft.ID, req)))
}
// Process those events; the draft will move to the sent folder and lose the draft flag.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
userContinueEventProcess(ctx, t, s, bridge)
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
messages, err := clientFetch(client, "Sent")
require.NoError(t, err)
require.Len(t, messages, 1)
require.NotContains(t, messages[0].Flags, imap.DraftFlag)
})
})
})
}
func TestBridge_User_DisableEnableAddress(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a user.
userID, _, err := s.CreateUser("user", password)
require.NoError(t, err)
// Create an additional address for the user.
aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password)
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil)))
// Initially we should list the address.
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
require.Contains(t, info.Addresses, "alias@"+s.GetDomain())
})
// Disable the address.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
require.NoError(t, c.DisableAddress(ctx, aliasID))
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Eventually we shouldn't list the address.
require.Eventually(t, func() bool {
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
return xslices.Index(info.Addresses, "alias@"+s.GetDomain()) < 0
}, 5*time.Second, 100*time.Millisecond)
})
// Enable the address.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
require.NoError(t, c.EnableAddress(ctx, aliasID))
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Eventually we should list the address.
require.Eventually(t, func() bool {
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
return xslices.Index(info.Addresses, "alias@"+s.GetDomain()) >= 0
}, 5*time.Second, 100*time.Millisecond)
})
})
}
func TestBridge_User_CreateDisabledAddress(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
// Create a user.
userID, _, err := s.CreateUser("user", password)
require.NoError(t, err)
// Create an additional address for the user.
aliasID, err := s.CreateAddress(userID, "alias@"+s.GetDomain(), password)
require.NoError(t, err)
// Immediately disable the address.
withClient(ctx, t, s, "user", password, func(ctx context.Context, c *proton.Client) {
require.NoError(t, c.DisableAddress(ctx, aliasID))
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, "user", password, nil, nil)))
// Initially we shouldn't list the address.
info, err := bridge.QueryUserInfo("user")
require.NoError(t, err)
require.NotContains(t, info.Addresses, "alias@"+s.GetDomain())
})
})
}
func TestBridge_User_HandleParentLabelRename(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
info, err := bridge.QueryUserInfo(username)
require.NoError(t, err)
client, err := client.Dial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))
defer func() { _ = client.Logout() }()
withClient(ctx, t, s, username, password, func(ctx context.Context, c *proton.Client) {
parentName := uuid.NewString()
childName := uuid.NewString()
// Create a folder.
parentLabel, err := c.CreateLabel(ctx, proton.CreateLabelReq{
Name: parentName,
Type: proton.LabelTypeFolder,
Color: "#f66",
})
require.NoError(t, err)
// Wait for the parent folder to be created.
require.Eventually(t, func() bool {
return xslices.IndexFunc(clientList(client), func(mailbox *imap.MailboxInfo) bool {
return mailbox.Name == fmt.Sprintf("Folders/%v", parentName)
}) >= 0
}, 100*user.EventPeriod, user.EventPeriod)
// Create a subfolder.
childLabel, err := c.CreateLabel(ctx, proton.CreateLabelReq{
Name: childName,
Type: proton.LabelTypeFolder,
Color: "#f66",
ParentID: parentLabel.ID,
})
require.NoError(t, err)
require.Equal(t, parentLabel.ID, childLabel.ParentID)
// Wait for the parent folder to be created.
require.Eventually(t, func() bool {
return xslices.IndexFunc(clientList(client), func(mailbox *imap.MailboxInfo) bool {
return mailbox.Name == fmt.Sprintf("Folders/%v/%v", parentName, childName)
}) >= 0
}, 100*user.EventPeriod, user.EventPeriod)
newParentName := uuid.NewString()
// Rename the parent folder.
require.NoError(t, getErr(c.UpdateLabel(ctx, parentLabel.ID, proton.UpdateLabelReq{
Color: "#f66",
Name: newParentName,
})))
// Wait for the parent folder to be renamed.
require.Eventually(t, func() bool {
return xslices.IndexFunc(clientList(client), func(mailbox *imap.MailboxInfo) bool {
return mailbox.Name == fmt.Sprintf("Folders/%v", newParentName)
}) >= 0
}, 100*user.EventPeriod, user.EventPeriod)
// Wait for the child folder to be renamed.
require.Eventually(t, func() bool {
return xslices.IndexFunc(clientList(client), func(mailbox *imap.MailboxInfo) bool {
return mailbox.Name == fmt.Sprintf("Folders/%v/%v", newParentName, childName)
}) >= 0
}, 100*user.EventPeriod, user.EventPeriod)
})
})
})
}
// userLoginAndSync logs in user and waits until user is fully synced.
func userLoginAndSync(
ctx context.Context,

View File

@ -37,9 +37,14 @@ func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, even
return fmt.Errorf("failed to handle user address created event: %w", err)
}
case events.UserAddressUpdated:
if err := bridge.handleUserAddressUpdated(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address updated event: %w", err)
case events.UserAddressEnabled:
if err := bridge.handleUserAddressEnabled(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address enabled event: %w", err)
}
case events.UserAddressDisabled:
if err := bridge.handleUserAddressDisabled(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address disabled event: %w", err)
}
case events.UserAddressDeleted:
@ -66,55 +71,84 @@ func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, even
}
func (bridge *Bridge) handleUserAddressCreated(ctx context.Context, user *user.User, event events.UserAddressCreated) error {
if user.GetAddressMode() == vault.SplitMode {
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
if user.GetAddressMode() != vault.SplitMode {
return nil
}
gluonID, err := bridge.imapServer.AddUser(ctx, user.NewIMAPConnector(event.AddressID), user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
gluonID, err := bridge.imapServer.AddUser(ctx, user.NewIMAPConnector(event.AddressID), user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
return nil
}
// GODT-1948: Handle addresses that have been disabled!
func (bridge *Bridge) handleUserAddressUpdated(_ context.Context, user *user.User, _ events.UserAddressUpdated) error {
switch user.GetAddressMode() {
case vault.CombinedMode:
return fmt.Errorf("not implemented")
func (bridge *Bridge) handleUserAddressEnabled(ctx context.Context, user *user.User, event events.UserAddressEnabled) error {
if user.GetAddressMode() != vault.SplitMode {
return nil
}
case vault.SplitMode:
return fmt.Errorf("not implemented")
gluonID, err := bridge.imapServer.AddUser(ctx, user.NewIMAPConnector(event.AddressID), user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserAddressDisabled(ctx context.Context, user *user.User, event events.UserAddressDisabled) error {
if user.GetAddressMode() != vault.SplitMode {
return nil
}
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
if err := user.RemoveGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to remove gluon ID for address: %w", err)
}
return nil
}
func (bridge *Bridge) handleUserAddressDeleted(ctx context.Context, user *user.User, event events.UserAddressDeleted) error {
if user.GetAddressMode() == vault.SplitMode {
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
if user.GetAddressMode() != vault.SplitMode {
return nil
}
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if bridge.imapServer == nil {
return fmt.Errorf("no imap server instance running")
}
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if err := user.RemoveGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to remove gluon ID for address: %w", err)
}
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
if err := user.RemoveGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to remove gluon ID for address: %w", err)
}
return nil

View File

@ -20,6 +20,8 @@ package bridge_test
import (
"context"
"fmt"
"net"
"net/http"
"testing"
"time"
@ -61,6 +63,50 @@ func TestBridge_Login(t *testing.T) {
})
}
func TestBridge_Login_DropConn(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
dropListener := proton.NewListener(l, proton.NewDropConn)
defer func() { _ = dropListener.Close() }()
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Login the user.
userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
// The user is now connected.
require.Equal(t, []string{userID}, bridge.GetUserIDs())
require.Equal(t, []string{userID}, getConnectedUserIDs(t, bridge))
})
// Whether to allow the user to be created.
var allowUser bool
s.AddStatusHook(func(req *http.Request) (int, bool) {
// Drop any request to the users endpoint.
if !allowUser && req.URL.Path == "/core/v4/users" {
dropListener.DropAll()
}
// After the ping request, allow the user to be created.
if req.URL.Path == "/tests/ping" {
allowUser = true
}
return 0, false
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// The user is eventually connected.
require.Eventually(t, func() bool {
return len(bridge.GetUserIDs()) == 1 && len(getConnectedUserIDs(t, bridge)) == 1
}, 5*time.Second, 100*time.Millisecond)
})
}, server.WithListener(dropListener))
}
func TestBridge_LoginTwice(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {

View File

@ -35,6 +35,30 @@ func (event UserAddressCreated) String() string {
return fmt.Sprintf("UserAddressCreated: UserID: %s, AddressID: %s, Email: %s", event.UserID, event.AddressID, logging.Sensitive(event.Email))
}
type UserAddressEnabled struct {
eventBase
UserID string
AddressID string
Email string
}
func (event UserAddressEnabled) String() string {
return fmt.Sprintf("UserAddressEnabled: UserID: %s, AddressID: %s, Email: %s", event.UserID, event.AddressID, logging.Sensitive(event.Email))
}
type UserAddressDisabled struct {
eventBase
UserID string
AddressID string
Email string
}
func (event UserAddressDisabled) String() string {
return fmt.Sprintf("UserAddressDisabled: UserID: %s, AddressID: %s, Email: %s", event.UserID, event.AddressID, logging.Sensitive(event.Email))
}
type UserAddressUpdated struct {
eventBase

76
internal/events/serve.go Normal file
View File

@ -0,0 +1,76 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package events
import "fmt"
type IMAPServerReady struct {
eventBase
Port int
}
func (event IMAPServerReady) String() string {
return fmt.Sprintf("IMAPServerReady: Port %d", event.Port)
}
type IMAPServerStopped struct {
eventBase
}
func (event IMAPServerStopped) String() string {
return "IMAPServerStopped"
}
type IMAPServerError struct {
eventBase
Error error
}
func (event IMAPServerError) String() string {
return fmt.Sprintf("IMAPServerError: %v", event.Error)
}
type SMTPServerReady struct {
eventBase
Port int
}
func (event SMTPServerReady) String() string {
return fmt.Sprintf("SMTPServerReady: Port %d", event.Port)
}
type SMTPServerStopped struct {
eventBase
}
func (event SMTPServerStopped) String() string {
return "SMTPServerStopped"
}
type SMTPServerError struct {
eventBase
Error error
}
func (event SMTPServerError) String() string {
return fmt.Sprintf("SMTPServerError: %v", event.Error)
}

View File

@ -169,6 +169,29 @@ func (event AddressModeChanged) String() string {
return fmt.Sprintf("AddressModeChanged: UserID: %s, AddressMode: %s", event.UserID, event.AddressMode)
}
// UsedSpaceChanged is emitted when the storage space used by the user has changed.
type UsedSpaceChanged struct {
eventBase
UserID string
UsedSpace int
}
func (event UsedSpaceChanged) String() string {
return fmt.Sprintf("UsedSpaceChanged: UserID: %s, UsedSpace: %v", event.UserID, event.UsedSpace)
}
type IMAPLoginFailed struct {
eventBase
Username string
}
func (event IMAPLoginFailed) String() string {
return fmt.Sprintf("IMAPLoginFailed: Username: %s", event.Username)
}
type UncategorizedEventError struct {
eventBase

View File

@ -21,9 +21,11 @@ import (
"context"
"fmt"
"net"
"path/filepath"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/focus/proto"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
@ -32,10 +34,10 @@ import (
// TryRaise tries to raise the application by dialing the focus service.
// It returns true if the service is running and the application was told to raise.
func TryRaise() bool {
func TryRaise(settingsPath string) bool {
var raised bool
if err := withClientConn(context.Background(), func(ctx context.Context, client proto.FocusClient) error {
if err := withClientConn(context.Background(), settingsPath, func(ctx context.Context, client proto.FocusClient) error {
if _, err := client.Raise(ctx, &emptypb.Empty{}); err != nil {
return fmt.Errorf("failed to call client.Raise: %w", err)
}
@ -53,10 +55,10 @@ func TryRaise() bool {
// TryVersion tries to determine the version of the running application instance.
// It returns the version and true if the version could be determined.
func TryVersion() (*semver.Version, bool) {
func TryVersion(settingsPath string) (*semver.Version, bool) {
var version *semver.Version
if err := withClientConn(context.Background(), func(ctx context.Context, client proto.FocusClient) error {
if err := withClientConn(context.Background(), settingsPath, func(ctx context.Context, client proto.FocusClient) error {
raw, err := client.Version(ctx, &emptypb.Empty{})
if err != nil {
return fmt.Errorf("failed to call client.Version: %w", err)
@ -78,10 +80,15 @@ func TryVersion() (*semver.Version, bool) {
return version, true
}
func withClientConn(ctx context.Context, fn func(context.Context, proto.FocusClient) error) error {
func withClientConn(ctx context.Context, settingsPath string, fn func(context.Context, proto.FocusClient) error) error {
var config = service.Config{}
err := config.Load(filepath.Join(settingsPath, serverConfigFileName))
if err != nil {
return err
}
cc, err := grpc.DialContext(
ctx,
net.JoinHostPort(Host, fmt.Sprint(Port)),
net.JoinHostPort(Host, fmt.Sprint(config.Port)),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {

View File

@ -18,19 +18,25 @@
package focus
import (
"os"
"testing"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/stretchr/testify/require"
)
func TestFocus_Raise(t *testing.T) {
tmpDir := t.TempDir()
locations := locations.New(newTestLocationsProvider(tmpDir), "config-name")
// Start the focus service.
service, err := NewService(semver.MustParse("1.2.3"))
service, err := NewService(locations, semver.MustParse("1.2.3"))
require.NoError(t, err)
settingsFolder, err := locations.ProvideSettingsPath()
require.NoError(t, err)
// Try to dial it, it should succeed.
require.True(t, TryRaise())
require.True(t, TryRaise(settingsFolder))
// The service should report a raise call.
<-service.GetRaiseCh()
@ -39,16 +45,60 @@ func TestFocus_Raise(t *testing.T) {
service.Close()
// Try to dial it, it should fail.
require.False(t, TryRaise())
require.False(t, TryRaise(settingsFolder))
}
func TestFocus_Version(t *testing.T) {
tmpDir := t.TempDir()
locations := locations.New(newTestLocationsProvider(tmpDir), "config-name")
// Start the focus service.
_, err := NewService(semver.MustParse("1.2.3"))
_, err := NewService(locations, semver.MustParse("1.2.3"))
require.NoError(t, err)
settingsFolder, err := locations.ProvideSettingsPath()
require.NoError(t, err)
// Try to dial it, it should succeed.
version, ok := TryVersion()
version, ok := TryVersion(settingsFolder)
require.True(t, ok)
require.Equal(t, "1.2.3", version.String())
}
type TestLocationsProvider struct {
config, data, cache string
}
func newTestLocationsProvider(dir string) *TestLocationsProvider {
config, err := os.MkdirTemp(dir, "config")
if err != nil {
panic(err)
}
data, err := os.MkdirTemp(dir, "data")
if err != nil {
panic(err)
}
cache, err := os.MkdirTemp(dir, "cache")
if err != nil {
panic(err)
}
return &TestLocationsProvider{
config: config,
data: data,
cache: cache,
}
}
func (provider *TestLocationsProvider) UserConfig() string {
return provider.config
}
func (provider *TestLocationsProvider) UserData() string {
return provider.data
}
func (provider *TestLocationsProvider) UserCache() string {
return provider.cache
}

View File

@ -25,16 +25,16 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v3/internal/focus/proto"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
// Host is the local host to listen on.
const Host = "127.0.0.1"
// Port is the port to listen on.
var Port = 1042 // nolint:gochecknoglobals
const (
Host = "127.0.0.1"
serverConfigFileName = "grpcFocusServerConfig.json"
)
// Service is a gRPC service that can be used to raise the application.
type Service struct {
@ -47,26 +47,39 @@ type Service struct {
// NewService creates a new focus service.
// It listens on the local host and port 1042 (by default).
func NewService(version *semver.Version) (*Service, error) {
service := &Service{
func NewService(locator service.Locator, version *semver.Version) (*Service, error) {
serv := &Service{
server: grpc.NewServer(),
raiseCh: make(chan struct{}, 1),
version: version,
}
proto.RegisterFocusServer(service.server, service)
proto.RegisterFocusServer(serv.server, serv)
if listener, err := net.Listen("tcp", net.JoinHostPort(Host, fmt.Sprint(Port))); err != nil {
logrus.WithError(err).Warn("Failed to start focus service")
if listener, err := net.Listen("tcp", net.JoinHostPort(Host, fmt.Sprint(0))); err != nil {
logrus.WithError(err).Warn("Failed to start focus serv")
} else {
config := service.Config{}
// retrieve the port assigned by the system, so that we can put it in the config file.
address, ok := listener.Addr().(*net.TCPAddr)
if !ok {
return nil, fmt.Errorf("could not retrieve gRPC service listener address")
}
config.Port = address.Port
if path, err := service.SaveGRPCServerConfigFile(locator, &config, serverConfigFileName); err != nil {
logrus.WithError(err).WithField("path", path).Warn("Could not write focus gRPC service config file")
} else {
logrus.WithField("path", path).Info("Successfully saved gRPC Focus service config file")
}
go func() {
if err := service.server.Serve(listener); err != nil {
if err := serv.server.Serve(listener); err != nil {
fmt.Printf("failed to serve: %v", err)
}
}()
}
return service, nil
return serv, nil
}
// Raise implements the gRPC FocusService interface; it raises the application.

View File

@ -1,8 +1,8 @@
find_program(QMAKE_EXE "qmake")
find_program(QMAKE_EXE NAMES "qmake" "qmake6")
if (NOT QMAKE_EXE)
message(FATAL_ERROR "Could not locate qmake executable, make sur you have Qt 6 installed in that qmake is in your PATH environment variable.")
message(FATAL_ERROR "Could not locate qmake executable, make sure you have Qt 6 installed in that qmake is in your PATH environment variable.")
endif()
message(STATUS "Found qmake at ${QMAKE_EXE}")
execute_process(COMMAND "${QMAKE_EXE}" -query QT_INSTALL_PREFIX OUTPUT_VARIABLE QT_DIR OUTPUT_STRIP_TRAILING_WHITESPACE)
set(CMAKE_PREFIX_PATH ${QT_DIR} ${CMAKE_PREFIX_PATH})
set(CMAKE_PREFIX_PATH ${QT_DIR} ${CMAKE_PREFIX_PATH})

View File

@ -20,6 +20,7 @@
#include "Cert.h"
#include "GRPCService.h"
#include <bridgepp/Exception/Exception.h>
#include <bridgepp/BridgeUtils.h>
#include <bridgepp/GRPC/GRPCUtils.h>
#include <bridgepp/GRPC/GRPCConfig.h>
@ -33,7 +34,6 @@ using namespace grpc;
//****************************************************************************************************************************************************
GRPCServerWorker::GRPCServerWorker(QObject *parent)
: Worker(parent) {
}
@ -81,7 +81,7 @@ void GRPCServerWorker::run() {
config.port = port;
QString err;
if (!config.save(grpcServerConfigPath(), &err)) {
if (!config.save(grpcServerConfigPath(bridgepp::userConfigDir()), &err)) {
throw Exception(QString("Could not save gRPC server config. %1").arg(err));
}

View File

@ -192,17 +192,6 @@ Status GRPCService::IsAllMailVisible(ServerContext *, Empty const *request, Bool
}
//****************************************************************************************************************************************************
/// \param[out] response The response.
/// \return The status for the call.
//****************************************************************************************************************************************************
Status GRPCService::GoOs(ServerContext *, Empty const *, StringValue *response) {
app().log().debug(__FUNCTION__);
response->set_value(app().mainWindow().settingsTab().os().toStdString());
return Status::OK;
}
//****************************************************************************************************************************************************
/// \return The status for the call.
//****************************************************************************************************************************************************

View File

@ -51,7 +51,6 @@ public: // member functions.
grpc::Status IsBetaEnabled(::grpc::ServerContext *, ::google::protobuf::Empty const *, ::google::protobuf::BoolValue *response) override;
grpc::Status SetIsAllMailVisible(::grpc::ServerContext *context, ::google::protobuf::BoolValue const *request, ::google::protobuf::Empty *response) override;
grpc::Status IsAllMailVisible(::grpc::ServerContext *context, ::google::protobuf::Empty const *request, ::google::protobuf::BoolValue *response) override;
grpc::Status GoOs(::grpc::ServerContext *, ::google::protobuf::Empty const *, ::google::protobuf::StringValue *response) override;
grpc::Status TriggerReset(::grpc::ServerContext *, ::google::protobuf::Empty const *, ::google::protobuf::Empty *) override;
grpc::Status Version(::grpc::ServerContext *, ::google::protobuf::Empty const *, ::google::protobuf::StringValue *response) override;
grpc::Status LogsPath(::grpc::ServerContext *, ::google::protobuf::Empty const *, ::google::protobuf::StringValue *response) override;

View File

@ -52,7 +52,11 @@ UsersTab::UsersTab(QWidget *parent)
connect(ui_.tableUserList, &QTableView::doubleClicked, this, &UsersTab::onEditUserButton);
connect(ui_.buttonRemoveUser, &QPushButton::clicked, this, &UsersTab::onRemoveUserButton);
connect(ui_.buttonUserBadEvent, &QPushButton::clicked, this, &UsersTab::onSendUserBadEvent);
connect(ui_.buttonImapLoginFailed, &QPushButton::clicked, this, &UsersTab::onSendIMAPLoginFailedEvent);
connect(ui_.buttonUsedBytesChanged, &QPushButton::clicked, this, &UsersTab::onSendUsedBytesChangedEvent);
connect(ui_.checkUsernamePasswordError, &QCheckBox::toggled, this, &UsersTab::updateGUIState);
connect(ui_.checkSync, &QCheckBox::toggled, this, &UsersTab::onCheckSyncToggled);
connect(ui_.sliderSync, &QSlider::valueChanged, this, &UsersTab::onSliderSyncValueChanged);
users_.append(randomUser());
@ -155,16 +159,76 @@ void UsersTab::onSendUserBadEvent() {
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void UsersTab::onSendUsedBytesChangedEvent() {
SPUser const user = selectedUser();
int const index = this->selectedIndex();
if (!user) {
app().log().error(QString("%1 failed. Unkown user.").arg(__FUNCTION__));
return;
}
if (UserState::Connected != user->state()) {
app().log().error(QString("%1 failed. User is not connected").arg(__FUNCTION__));
}
qint64 const usedBytes = qint64(ui_.spinUsedBytes->value());
user->setUsedBytes(usedBytes);
users_.touch(index);
GRPCService &grpc = app().grpc();
if (grpc.isStreaming()) {
QString const userID = user->id();
grpc.sendEvent(newUsedBytesChangedEvent(userID, usedBytes));
}
this->updateGUIState();
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void UsersTab::onSendIMAPLoginFailedEvent() {
GRPCService &grpc = app().grpc();
if (grpc.isStreaming()) {
grpc.sendEvent(newIMAPLoginFailedEvent(ui_.editIMAPLoginFailedUsername->text()));
}
this->updateGUIState();
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void UsersTab::updateGUIState() {
SPUser const user = selectedUser();
bool const hasSelectedUser = user.get();
UserState const state = user ? user->state() : UserState::SignedOut;
ui_.buttonEditUser->setEnabled(hasSelectedUser);
ui_.buttonRemoveUser->setEnabled(hasSelectedUser);
ui_.groupBoxBadEvent->setEnabled(hasSelectedUser && (UserState::SignedOut != user->state()));
ui_.groupBoxBadEvent->setEnabled(hasSelectedUser && (UserState::SignedOut != state));
ui_.groupBoxUsedSpace->setEnabled(hasSelectedUser && (UserState::Connected == state));
ui_.editUsernamePasswordError->setEnabled(ui_.checkUsernamePasswordError->isChecked());
ui_.spinUsedBytes->setValue(user ? user->usedBytes() : 0.0);
ui_.groupboxSync->setEnabled(user.get());
if (user)
ui_.editIMAPLoginFailedUsername->setText(user->primaryEmailOrUsername());
QSignalBlocker b(ui_.checkSync);
bool const syncing = user && user->isSyncing();
ui_.checkSync->setChecked(syncing);
b = QSignalBlocker(ui_.sliderSync);
ui_.sliderSync->setEnabled(syncing);
qint32 const progressPercent = syncing ? qint32(user->syncProgress() * 100.0f) : 0;
ui_.sliderSync->setValue(progressPercent);
ui_.labelSync->setText(syncing ? QString("%1%").arg(progressPercent) : "" );
}
@ -368,3 +432,44 @@ void UsersTab::processBadEventUserFeedback(QString const &userID, bool doResync)
this->updateGUIState();
}
//****************************************************************************************************************************************************
/// \param[in] checked Is the sync checkbox checked?
//****************************************************************************************************************************************************
void UsersTab::onCheckSyncToggled(bool checked) {
SPUser const user = this->selectedUser();
if ((!user) || (user->isSyncing() == checked)) {
return;
}
user->setIsSyncing(checked);
user->setSyncProgress(0.0);
GRPCService &grpc = app().grpc();
// we do not apply delay for these event.
if (checked) {
grpc.sendEvent(newSyncStartedEvent(user->id()));
grpc.sendEvent(newSyncProgressEvent(user->id(), 0.0, 1, 1));
} else {
grpc.sendEvent(newSyncFinishedEvent(user->id()));
}
this->updateGUIState();
}
//****************************************************************************************************************************************************
/// \param[in] value The value for the slider.
//****************************************************************************************************************************************************
void UsersTab::onSliderSyncValueChanged(int value) {
SPUser const user = this->selectedUser();
if ((!user) || (!user->isSyncing()) || user->syncProgress() == value) {
return;
}
double const progress = value / 100.0;
user->setSyncProgress(progress);
app().grpc().sendEvent(newSyncProgressEvent(user->id(), progress, 1, 1)); // we do not simulate elapsed & remaining.
this->updateGUIState();
}

View File

@ -62,6 +62,10 @@ private slots:
void onRemoveUserButton(); ///< Remove the currently selected user.
void onSelectionChanged(QItemSelection, QItemSelection); ///< Slot for the change of the selection.
void onSendUserBadEvent(); ///< Slot for the 'Send Bad Event Error' button.
void onSendUsedBytesChangedEvent(); ///< Slot for the 'Send Used Bytes Changed Event' button.
void onSendIMAPLoginFailedEvent(); ///< Slot for the 'Send IMAP Login failure Event' button.
void onCheckSyncToggled(bool checked); ///< Slot for the 'Synchronizing' check box.
void onSliderSyncValueChanged(int value); ///< Slot for the sync 'Progress' slider.
void updateGUIState(); ///< Update the GUI state.
private: // member functions.

View File

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>1225</width>
<height>717</height>
<width>1221</width>
<height>894</height>
</rect>
</property>
<property name="windowTitle">
@ -66,6 +66,52 @@
</property>
</spacer>
</item>
<item>
<widget class="QGroupBox" name="groupboxSync">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Sync</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4" stretch="1,0">
<item>
<widget class="QCheckBox" name="checkSync">
<property name="text">
<string>Synchronizing</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelSync">
<property name="text">
<string>0%</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QSlider" name="sliderSync">
<property name="maximum">
<number>100</number>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="tickInterval">
<number>10</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBoxBadEvent">
<property name="minimumSize">
@ -80,13 +126,6 @@
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="labelUserBadEvent">
<property name="text">
<string>Message: </string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="editUserBadEvent">
<property name="minimumSize">
@ -96,18 +135,102 @@
</size>
</property>
<property name="text">
<string>Bad event error.</string>
<string/>
</property>
<property name="placeholderText">
<string>error message</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUserBadEvent">
<property name="text">
<string>Send</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBoxUsedSpace">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>Used Bytes Changed</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QPushButton" name="buttonUserBadEvent">
<property name="text">
<string>Send Bad Event Error</string>
</property>
</widget>
<layout class="QHBoxLayout" name="hBoxUsedBytes" stretch="1,0">
<item>
<widget class="QDoubleSpinBox" name="spinUsedBytes">
<property name="buttonSymbols">
<enum>QAbstractSpinBox::NoButtons</enum>
</property>
<property name="decimals">
<number>0</number>
</property>
<property name="maximum">
<double>1000000000000000.000000000000000</double>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUsedBytesChanged">
<property name="text">
<string>Send</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBoxIMAPLoginFailed">
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="title">
<string>IMAP Login Failure</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QLineEdit" name="editIMAPLoginFailedUsername">
<property name="minimumSize">
<size>
<width>200</width>
<height>0</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="placeholderText">
<string>username or primary email</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonImapLoginFailed">
<property name="text">
<string>Send</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>

View File

@ -85,7 +85,10 @@ int main(int argc, char **argv) {
return exitCode;
}
catch (Exception const &e) {
QTextStream(stderr) << QString("A fatal error occurred: %1\n").arg(e.qwhat());
QString message = e.qwhat();
if (!e.details().isEmpty())
message += "\n\nDetails:\n" + e.details();
QTextStream(stderr) << QString("A fatal error occurred: %1\n").arg(message);
return EXIT_FAILURE;
}
}

View File

@ -19,6 +19,7 @@
#include "AppController.h"
#include "QMLBackend.h"
#include "SentryUtils.h"
#include "Settings.h"
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/Exception/Exception.h>
#include <bridgepp/ProcessMonitor.h>
@ -48,10 +49,19 @@ AppController &app() {
AppController::AppController()
: backend_(std::make_unique<QMLBackend>())
, grpc_(std::make_unique<GRPCClient>())
, log_(std::make_unique<Log>()) {
, log_(std::make_unique<Log>())
, settings_(new Settings) {
}
//****************************************************************************************************************************************************
// The following is in the implementation file because of unique pointers with incomplete types in headers.
// See https://stackoverflow.com/questions/6012157/is-stdunique-ptrt-required-to-know-the-full-definition-of-t
//****************************************************************************************************************************************************
AppController::~AppController() = default;
//****************************************************************************************************************************************************
/// \return The bridge worker, which can be null if the application was run in 'attach' mode (-a command-line switch).
//****************************************************************************************************************************************************
@ -71,6 +81,14 @@ ProcessMonitor *AppController::bridgeMonitor() const {
}
//****************************************************************************************************************************************************
/// \return A reference to the application settings.
//****************************************************************************************************************************************************
Settings &AppController::settings() {
return *settings_;
}
//****************************************************************************************************************************************************
/// \param[in] exception The exception that triggered the fatal error.
//****************************************************************************************************************************************************
@ -102,4 +120,4 @@ void AppController::restart(bool isCrashing) {
void AppController::setLauncherArgs(const QString &launcher, const QStringList &args) {
launcher_ = launcher;
launcherArgs_ = args;
}
}

View File

@ -20,8 +20,9 @@
#define BRIDGE_GUI_APP_CONTROLLER_H
// @formatter:off
//@formatter:off
class QMLBackend;
class Settings;
namespace bridgepp {
class Log;
class Overseer;
@ -29,7 +30,7 @@ class GRPCClient;
class ProcessMonitor;
class Exception;
}
// @formatter:off
//@formatter:on
//****************************************************************************************************************************************************
@ -42,7 +43,7 @@ Q_OBJECT
public: // member functions.
AppController(AppController const &) = delete; ///< Disabled copy-constructor.
AppController(AppController &&) = delete; ///< Disabled assignment copy-constructor.
~AppController() override = default; ///< Destructor.
~AppController() override; ///< Destructor.
AppController &operator=(AppController const &) = delete; ///< Disabled assignment operator.
AppController &operator=(AppController &&) = delete; ///< Disabled move assignment operator.
QMLBackend &backend() { return *backend_; } ///< Return a reference to the backend.
@ -50,7 +51,8 @@ public: // member functions.
bridgepp::Log &log() { return *log_; } ///< Return a reference to the log.
std::unique_ptr<bridgepp::Overseer> &bridgeOverseer() { return bridgeOverseer_; }; ///< Returns a reference the bridge overseer
bridgepp::ProcessMonitor *bridgeMonitor() const; ///< Return the bridge worker.
void setLauncherArgs(const QString& launcher, const QStringList& args);
Settings &settings();; ///< Return the application settings.
void setLauncherArgs(const QString &launcher, const QStringList &args);
public slots:
void onFatalError(bridgepp::Exception const& e); ///< Handle fatal errors.
@ -64,6 +66,7 @@ private: // data members
std::unique_ptr<bridgepp::GRPCClient> grpc_; ///< The RPC client.
std::unique_ptr<bridgepp::Log> log_; ///< The log.
std::unique_ptr<bridgepp::Overseer> bridgeOverseer_; ///< The overseer for the bridge monitor worker.
std::unique_ptr<Settings> settings_; ///< The application settings.
QString launcher_;
QStringList launcherArgs_;
};

View File

@ -19,12 +19,13 @@
#ifndef BRIDGE_GUI_VERSION_H
#define BRIDGE_GUI_VERSION_H
#define PROJECT_FULL_NAME "@BRIDGE_APP_FULL_NAME@"
#define PROJECT_VENDOR "@BRIDGE_VENDOR@"
#define PROJECT_VER "@BRIDGE_APP_VERSION@"
#define PROJECT_REVISION "@BRIDGE_REVISION@"
#define PROJECT_BUILD_TIME "@BRIDGE_BUILD_TIME@"
#define PROJECT_DSN_SENTRY "@BRIDGE_DSN_SENTRY@"
#define PROJECT_BUILD_ENV "@BRIDGE_BUILD_ENV@"
#define PROJECT_FULL_NAME "@BRIDGE_APP_FULL_NAME@"
#define PROJECT_VENDOR "@BRIDGE_VENDOR@"
#define PROJECT_VER "@BRIDGE_APP_VERSION@"
#define PROJECT_REVISION "@BRIDGE_REVISION@"
#define PROJECT_BUILD_TIME "@BRIDGE_BUILD_TIME@"
#define PROJECT_DSN_SENTRY "@BRIDGE_DSN_SENTRY@"
#define PROJECT_BUILD_ENV "@BRIDGE_BUILD_ENV@"
#define PROJECT_CRASHPAD_HANDLER_PATH "@BRIDGE_CRASHPAD_HANDLER_PATH@"
#endif // BRIDGE_GUI_VERSION_H

View File

@ -85,7 +85,6 @@ message(STATUS "Using Qt ${Qt6_VERSION}")
#*****************************************************************************************************************************************************
find_package(sentry CONFIG REQUIRED)
#*****************************************************************************************************************************************************
# Source files and output
#*****************************************************************************************************************************************************
@ -110,24 +109,23 @@ add_executable(bridge-gui
Resources.qrc
AppController.cpp AppController.h
BridgeApp.cpp BridgeApp.h
BuildConfig.h
CommandLine.cpp CommandLine.h
EventStreamWorker.cpp EventStreamWorker.h
LogUtils.cpp LogUtils.h
main.cpp
Pch.h
BuildConfig.h
QMLBackend.cpp QMLBackend.h
UserList.cpp UserList.h
SentryUtils.cpp SentryUtils.h
Settings.cpp Settings.h
${DOCK_ICON_SRC_FILE} MacOS/DockIcon.h
)
if (APPLE)
target_sources(bridge-gui PRIVATE MacOS/SecondInstance.mm MacOS/SecondInstance.h)
endif(APPLE)
if (WIN32) # on Windows, we add a (non-Qt) resource file that contains the application icon and version information.
string(TIMESTAMP BRIDGE_BUILD_YEAR "%Y")
set(REGEX_NUMBER "[0123456789]") # CMake matches does not support \d.

View File

@ -17,6 +17,7 @@
#include "LogUtils.h"
#include "BuildConfig.h"
#include <bridgepp/BridgeUtils.h>
@ -24,9 +25,52 @@ using namespace bridgepp;
namespace {
qsizetype const logFileTailMaxLength = 25 * 1024; ///< The maximum length of the portion of log returned by tailOfLatestBridgeLog()
qsizetype const logFileTailMaxLength = 25 * 1024; ///< The maximum length of the portion of log returned by tailOfLatestBridgeLog()
}
//****************************************************************************************************************************************************
/// \return user logs directory used by bridge.
//****************************************************************************************************************************************************
QString userLogsDir() {
QString const path = QDir(bridgepp::userDataDir()).absoluteFilePath("logs");
QDir().mkpath(path);
return path;
}
//****************************************************************************************************************************************************
/// \return A reference to the log.
//****************************************************************************************************************************************************
Log &initLog() {
Log &log = app().log();
log.registerAsQtMessageHandler();
log.setEchoInConsole(true);
// remove old gui log files
QDir const logsDir(userLogsDir());
for (QFileInfo const fileInfo: logsDir.entryInfoList({ "gui_v*.log" }, QDir::Filter::Files)) { // entryInfolist apparently only support wildcards, not regex.
QFile(fileInfo.absoluteFilePath()).remove();
}
// create new GUI log file
QString error;
if (!log.startWritingToFile(logsDir.absoluteFilePath(QString("gui_v%1_%2.log").arg(PROJECT_VER).arg(QDateTime::currentSecsSinceEpoch())), &error)) {
log.error(error);
}
log.info("bridge-gui starting");
QString const qtCompileTimeVersion = QT_VERSION_STR;
QString const qtRuntimeVersion = qVersion();
QString msg = QString("Using Qt %1").arg(qtRuntimeVersion);
if (qtRuntimeVersion != qtCompileTimeVersion) {
msg += QString(" (compiled against %1)").arg(qtCompileTimeVersion);
}
log.info(msg);
return log;
}
//****************************************************************************************************************************************************
/// \brief Return the path of the latest bridge log.
/// \return The path of the latest bridge log file.
@ -58,5 +102,3 @@ QByteArray tailOfLatestBridgeLog() {
return file.open(QIODevice::Text | QIODevice::ReadOnly) ? file.readAll().right(logFileTailMaxLength) : QByteArray();
}

View File

@ -20,6 +20,10 @@
#define BRIDGE_GUI_LOG_UTILS_H
#include <bridgepp/Log/Log.h>
bridgepp::Log &initLog(); ///< Initialize the application log.
QByteArray tailOfLatestBridgeLog(); ///< Return the last bytes of the last bridge log.

View File

@ -17,11 +17,12 @@
#include "QMLBackend.h"
#include "EventStreamWorker.h"
#include "BuildConfig.h"
#include "EventStreamWorker.h"
#include "LogUtils.h"
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/BridgeUtils.h>
#include <bridgepp/Exception/Exception.h>
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/Worker/Overseer.h>
#include <bridgepp/BridgeUtils.h>
@ -58,7 +59,7 @@ void QMLBackend::init(GRPCConfig const &serviceConfig) {
app().grpc().setLog(&log);
this->connectGrpcEvents();
app().grpc().connectToServer(serviceConfig, app().bridgeMonitor());
app().grpc().connectToServer(bridgepp::userConfigDir(), serviceConfig, app().bridgeMonitor());
app().log().info("Connected to backend via gRPC service.");
QString bridgeVer;
@ -178,16 +179,6 @@ void QMLBackend::setShowSplashScreen(bool show) {
}
//****************************************************************************************************************************************************
/// \return The value for the 'showSplashScreen' property.
//****************************************************************************************************************************************************
bool QMLBackend::showSplashScreen() const {
HANDLE_EXCEPTION_RETURN_BOOL(
return showSplashScreen_;
)
}
//****************************************************************************************************************************************************
/// \return The value for the 'GOOS' property.
//****************************************************************************************************************************************************
@ -198,6 +189,16 @@ QString QMLBackend::goos() const {
}
//****************************************************************************************************************************************************
/// \return The value for the 'showSplashScreen' property.
//****************************************************************************************************************************************************
bool QMLBackend::showSplashScreen() const {
HANDLE_EXCEPTION_RETURN_BOOL(
return showSplashScreen_;
)
}
//****************************************************************************************************************************************************
/// \return The value for the 'logsPath' property.
//****************************************************************************************************************************************************
@ -465,6 +466,7 @@ bool QMLBackend::isDoHEnabled() const {
)
}
//****************************************************************************************************************************************************
/// \return The value for the 'isAutomaticUpdateOn' property.
//****************************************************************************************************************************************************
@ -910,6 +912,24 @@ void QMLBackend::onUserBadEvent(QString const &userID, QString const& ) {
}
//****************************************************************************************************************************************************
/// \param[in] username The username (or primary email address)
//****************************************************************************************************************************************************
void QMLBackend::onIMAPLoginFailed(QString const &username) {
HANDLE_EXCEPTION(
SPUser const user = users_->getUserWithUsernameOrEmail(username);
if ((!user) || (user->state() != UserState::SignedOut)) { // We want to pop-up only if a signed-out user has been detected
return;
}
if (user->isInIMAPLoginFailureCooldown())
return;
user->startImapLoginFailureCooldown(60 * 60 * 1000); // 1 hour cooldown during which we will not display this notification to this user again.
emit selectUser(user->id());
emit imapLoginWhileSignedOut(username);
)
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
@ -1018,6 +1038,8 @@ void QMLBackend::connectGrpcEvents() {
// user events
connect(client, &GRPCClient::userDisconnected, this, &QMLBackend::userDisconnected);
connect(client, &GRPCClient::userBadEvent, this, &QMLBackend::onUserBadEvent);
connect(client, &GRPCClient::imapLoginFailed, this, &QMLBackend::onIMAPLoginFailed);
users_->connectGRPCEvents();
}

View File

@ -181,6 +181,7 @@ public slots: // slot for signals received from gRPC that need transformation in
void onLoginFinished(QString const &userID, bool wasSignedOut); ///< Slot for LoginFinished gRPC event.
void onLoginAlreadyLoggedIn(QString const &userID); ///< Slot for the LoginAlreadyLoggedIn gRPC event.
void onUserBadEvent(QString const& userID, QString const& errorMessage); ///< Slot for the userBadEvent gRPC event.
void onIMAPLoginFailed(QString const& username); ///< Slot the the imapLoginFailed event.
signals: // Signals received from the Go backend, to be forwarded to QML
void toggleAutostartFinished(); ///< Signal for the 'toggleAutostartFinished' gRPC stream event.
@ -234,6 +235,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void hideMainWindow(); ///< Signal for the 'hideMainWindow' gRPC stream event.
void genericError(QString const &title, QString const &description); ///< Signal for the 'genericError' gRPC stream event.
void selectUser(QString const); ///< Signal that request the given user account to be displayed.
void imapLoginWhileSignedOut(QString const& username); ///< Signal for the notification of IMAP login attempt on a signed out account.
// This signal is emitted when an exception is intercepted is calls triggered by QML. QML engine would intercept the exception otherwise.
void fatalError(bridgepp::Exception const& e) const; ///< Signal emitted when an fatal error occurs.

View File

@ -20,10 +20,8 @@
#include <bridgepp/BridgeUtils.h>
#include <bridgepp/Exception/Exception.h>
#include <QByteArray>
#include <QCryptographicHash>
#include <QString>
#include <QSysInfo>
using namespace bridgepp;
static constexpr const char *LoggerName = "bridge-gui";
@ -46,25 +44,54 @@ QString sentryAttachmentFilePath() {
}
//****************************************************************************************************************************************************
/// \brief Get a hash of the computer's host name
//****************************************************************************************************************************************************
QByteArray getProtectedHostname() {
QByteArray hostname = QCryptographicHash::hash(QSysInfo::machineHostName().toUtf8(), QCryptographicHash::Sha256);
return hostname.toHex();
}
//****************************************************************************************************************************************************
/// \return The OS String used by sentry
//****************************************************************************************************************************************************
QString getApiOS() {
#if defined(Q_OS_DARWIN)
return "macos";
#elif defined(Q_OS_WINDOWS)
return "windows";
#else
return "linux";
#endif
switch (os()) {
case OS::MacOS:
return "macos";
case OS::Windows:
return "windows";
case OS::Linux:
default:
return "linux";
}
}
//****************************************************************************************************************************************************
/// \return The application version number.
//****************************************************************************************************************************************************
QString appVersion(const QString& version) {
return QString("%1-bridge@%2").arg(getApiOS()).arg(version);
return QString("%1-bridge@%2").arg(getApiOS(), version);
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void initSentry() {
sentry_options_t *sentryOptions = newSentryOptions(PROJECT_DSN_SENTRY, sentryCacheDir().toStdString().c_str());
if (!QString(PROJECT_CRASHPAD_HANDLER_PATH).isEmpty())
sentry_options_set_handler_path(sentryOptions, PROJECT_CRASHPAD_HANDLER_PATH);
if (sentry_init(sentryOptions) != 0) {
QTextStream(stderr) << "Failed to initialize sentry\n";
}
setSentryReportScope();
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void setSentryReportScope() {
sentry_set_tag("OS", bridgepp::goos().toUtf8());
sentry_set_tag("Client", PROJECT_FULL_NAME);
@ -76,6 +103,10 @@ void setSentryReportScope() {
sentry_set_user(user);
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
sentry_options_t* newSentryOptions(const char *sentryDNS, const char *cacheDir) {
sentry_options_t *sentryOptions = sentry_options_new();
sentry_options_set_dsn(sentryOptions, sentryDNS);
@ -92,12 +123,18 @@ sentry_options_t* newSentryOptions(const char *sentryDNS, const char *cacheDir)
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message) {
auto event = sentry_value_new_message_event(level, LoggerName, message);
return sentry_capture_event(event);
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
sentry_uuid_t reportSentryException(sentry_level_t level, const char *message, const char *exceptionType, const char *exception) {
auto event = sentry_value_new_message_event(level, LoggerName, message);
sentry_event_add_exception(event, sentry_value_new_exception(exceptionType, exception));

View File

@ -21,7 +21,7 @@
#include <sentry.h>
void initSentry();
void setSentryReportScope();
sentry_options_t* newSentryOptions(const char * sentryDNS, const char * cacheDir);
sentry_uuid_t reportSentryEvent(sentry_level_t level, const char *message);

View File

@ -0,0 +1,56 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "Settings.h"
#include <bridgepp/BridgeUtils.h>
using namespace bridgepp;
namespace {
QString const settingsFileName = "bridge-gui.ini"; ///< The name of the settings file.
QString const keyUseSoftwareRenderer = "UseSoftwareRenderer"; ///< The key for storing the 'Use software rendering' setting.
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
Settings::Settings()
: settings_(QDir(userConfigDir()).absoluteFilePath("bridge-gui.ini"), QSettings::Format::IniFormat) {
}
//****************************************************************************************************************************************************
/// \return The value for the 'Use software renderer' setting.
//****************************************************************************************************************************************************
bool Settings::useSoftwareRenderer() const {
return settings_.value(keyUseSoftwareRenderer, false).toBool();
}
//****************************************************************************************************************************************************
/// \param[in] value The value for the 'Use software renderer' setting.
//****************************************************************************************************************************************************
void Settings::setUseSoftwareRenderer(bool value) {
settings_.setValue(keyUseSoftwareRenderer, value);
}

View File

@ -0,0 +1,47 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#ifndef BRIDGE_GUI_SETTINGS_H
#define BRIDGE_GUI_SETTINGS_H
//****************************************************************************************************************************************************
/// \brief Application settings class
//****************************************************************************************************************************************************
class Settings {
public: // member functions.
Settings(Settings const&) = delete; ///< Disabled copy-constructor.
Settings(Settings&&) = delete; ///< Disabled assignment copy-constructor.
~Settings() = default; ///< Destructor.
Settings& operator=(Settings const&) = delete; ///< Disabled assignment operator.
Settings& operator=(Settings&&) = delete; ///< Disabled move assignment operator.
bool useSoftwareRenderer() const; ///< Get the 'Use software renderer' settings value.
void setUseSoftwareRenderer(bool value); ///< Set the 'Use software renderer' settings value.
private: // member functions.
Settings(); ///< Default constructor.
private: // data members.
QSettings settings_; ///< The settings.
friend class AppController;
};
#endif //BRIDGE_GUI_SETTINGS_H

View File

@ -38,6 +38,10 @@ void UserList::connectGRPCEvents() const {
GRPCClient &client = app().grpc();
connect(&client, &GRPCClient::userChanged, this, &UserList::onUserChanged);
connect(&client, &GRPCClient::toggleSplitModeFinished, this, &UserList::onToggleSplitModeFinished);
connect(&client, &GRPCClient::usedBytesChanged, this, &UserList::onUsedBytesChanged);
connect(&client, &GRPCClient::syncStarted, this, &UserList::onSyncStarted);
connect(&client, &GRPCClient::syncFinished, this, &UserList::onSyncFinished);
connect(&client, &GRPCClient::syncProgress, this, &UserList::onSyncProgress);
}
@ -148,6 +152,19 @@ bridgepp::SPUser UserList::getUserWithID(QString const &userID) const {
}
//****************************************************************************************************************************************************
/// \param[in] username The username or email.
/// \return The user with the given ID.
/// \return A null pointer if the user could not be found.
//****************************************************************************************************************************************************
bridgepp::SPUser UserList::getUserWithUsernameOrEmail(QString const &username) const {
QList<SPUser>::const_iterator it = std::find_if(users_.begin(), users_.end(), [username](SPUser const &user) -> bool {
return user && ((username.compare(user->username(), Qt::CaseInsensitive) == 0) || user->addresses().contains(username, Qt::CaseInsensitive));
});
return (it == users_.end()) ? nullptr : *it;
}
//****************************************************************************************************************************************************
/// \param[in] row The row.
//****************************************************************************************************************************************************
@ -223,3 +240,61 @@ void UserList::onToggleSplitModeFinished(QString const &userID) {
int UserList::count() const {
return users_.size();
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \param[in] usedBytes The used space, in bytes.
//****************************************************************************************************************************************************
void UserList::onUsedBytesChanged(QString const &userID, qint64 usedBytes) {
int const index = this->rowOfUserID(userID);
if (index < 0) {
app().log().error(QString("Received usedBytesChanged event for unknown userID %1").arg(userID));
return;
}
users_[index]->setUsedBytes(usedBytes);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
//****************************************************************************************************************************************************
void UserList::onSyncStarted(QString const &userID) {
int const index = this->rowOfUserID(userID);
if (index < 0) {
app().log().error(QString("Received onSyncStarted event for unknown userID %1").arg(userID));
return;
}
users_[index]->setIsSyncing(true);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
//****************************************************************************************************************************************************
void UserList::onSyncFinished(QString const &userID) {
int const index = this->rowOfUserID(userID);
if (index < 0) {
app().log().error(QString("Received onSyncFinished event for unknown userID %1").arg(userID));
return;
}
users_[index]->setIsSyncing(false);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \param[in] progress The sync progress ratio.
/// \param[in] elapsedMs The elapsed sync time in milliseconds.
/// \param[in] remainingMs The remaining sync time in milliseconds.
//****************************************************************************************************************************************************
void UserList::onSyncProgress(QString const &userID, double progress, float elapsedMs, float remainingMs) {
Q_UNUSED(elapsedMs)
Q_UNUSED(remainingMs)
int const index = this->rowOfUserID(userID);
if (index < 0) {
app().log().error(QString("Received onSyncFinished event for unknown userID %1").arg(userID));
return;
}
users_[index]->setSyncProgress(progress);
}

View File

@ -44,6 +44,7 @@ public: // member functions.
void appendUser(bridgepp::SPUser const &user); ///< Add a new user.
void updateUserAtRow(int row, bridgepp::User const &user); ///< Update the user at given row.
bridgepp::SPUser getUserWithID(QString const &userID) const; ///< Retrieve the user with the given ID.
bridgepp::SPUser getUserWithUsernameOrEmail(QString const& username) const; ///< Retrieve the user with the given primary email address or username
// the userCount property.
Q_PROPERTY(int count READ count NOTIFY countChanged)
@ -59,7 +60,11 @@ public:
public slots: ///< handler for signals coming from the gRPC service
void onUserChanged(QString const &userID);
void onToggleSplitModeFinished(QString const &userID);
void onUsedBytesChanged(QString const &userID, qint64 usedBytes); ///< Slot for usedBytesChanged events.
void onSyncStarted(QString const &userID); ///< Slot for syncStarted events.
void onSyncFinished(QString const &userID); ///< Slot for syncFinished events.
void onSyncProgress(QString const &userID, double progress, float elapsedMs, float remainingMs); ///< Slot for syncFinished events.
private: // data members
QList<bridgepp::SPUser> users_; ///< The user list.
};

View File

@ -92,7 +92,7 @@ git submodule update --init --recursive $vcpkgRoot
. $cmakeExe -G "Visual Studio 17 2022" -DCMAKE_BUILD_TYPE="$buildConfig" `
-DBRIDGE_APP_FULL_NAME="$bridgeFullName" `
-DBRIDGE_VENDOR="$bridgeVendor" `
-DBRIDGE_REVISION=$REVISION_HASH `
-DBRIDGE_REVISION="$REVISION_HASH" `
-DBRIDGE_APP_VERSION="$bridgeVersion" `
-DBRIDGE_BUILD_TIME="$bridgeBuidTime" `
-DBRIDGE_DSN_SENTRY="$bridgeDsnSentry" `

View File

@ -16,20 +16,18 @@
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "Pch.h"
#include "BridgeApp.h"
#include "BuildConfig.h"
#include "CommandLine.h"
#include "LogUtils.h"
#include "QMLBackend.h"
#include "SentryUtils.h"
#include "BuildConfig.h"
#include "LogUtils.h"
#include "Settings.h"
#include <bridgepp/BridgeUtils.h>
#include <bridgepp/Exception/Exception.h>
#include <bridgepp/FocusGRPC/FocusGRPCClient.h>
#include <bridgepp/Log/Log.h>
#include <bridgepp/ProcessMonitor.h>
#include <sentry.h>
#include <SentryUtils.h>
#ifdef Q_OS_MACOS
@ -99,38 +97,6 @@ void initQtApplication() {
}
//****************************************************************************************************************************************************
/// \return A reference to the log.
//****************************************************************************************************************************************************
Log &initLog() {
Log &log = app().log();
log.registerAsQtMessageHandler();
log.setEchoInConsole(true);
// remove old gui log files
QDir const logsDir(userLogsDir());
for (QFileInfo const fileInfo: logsDir.entryInfoList({ "gui_v*.log" }, QDir::Filter::Files)) { // entryInfolist apparently only support wildcards, not regex.
QFile(fileInfo.absoluteFilePath()).remove();
}
// create new GUI log file
QString error;
if (!log.startWritingToFile(logsDir.absoluteFilePath(QString("gui_v%1_%2.log").arg(PROJECT_VER).arg(QDateTime::currentSecsSinceEpoch())), &error)) {
log.error(error);
}
log.info("bridge-gui starting");
QString const qtCompileTimeVersion = QT_VERSION_STR;
QString const qtRuntimeVersion = qVersion();
QString msg = QString("Using Qt %1").arg(qtRuntimeVersion);
if (qtRuntimeVersion != qtCompileTimeVersion) {
msg += QString(" (compiled against %1)").arg(qtCompileTimeVersion);
}
log.info(msg);
return log;
}
//****************************************************************************************************************************************************
/// \param[in] engine The QML component.
@ -150,8 +116,9 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine) {
rootComponent->loadUrl(QUrl(qrcQmlDir + "/Bridge.qml"));
if (rootComponent->status() != QQmlComponent::Status::Ready) {
app().log().error(rootComponent->errorString());
throw Exception("Could not load QML component");
QString const &err =rootComponent->errorString();
app().log().error(err);
throw Exception("Could not load QML component", err);
}
return rootComponent;
}
@ -218,7 +185,7 @@ QUrl getApiUrl() {
/// \return true if an instance of bridge is already running.
//****************************************************************************************************************************************************
bool isBridgeRunning() {
QLockFile lockFile(QDir(userCacheDir()).absoluteFilePath(bridgeLock));
QLockFile lockFile(QDir(bridgepp::userCacheDir()).absoluteFilePath(bridgeLock));
return (!lockFile.tryLock()) && (lockFile.error() == QLockFile::LockFailedError);
}
@ -229,8 +196,21 @@ bool isBridgeRunning() {
void focusOtherInstance() {
try {
FocusGRPCClient client;
GRPCConfig sc;
QString const path = FocusGRPCClient::grpcFocusServerConfigPath(bridgepp::userConfigDir());
QFile file(path);
if (file.exists()) {
if (!sc.load(path)) {
throw Exception("The gRPC focus service configuration file is invalid.");
}
}
else {
throw Exception("Server did not provide gRPC Focus service configuration.");
}
QString error;
if (!client.connectToServer(5000, &error)) {
if (!client.connectToServer(5000, sc.port, &error)) {
throw Exception(QString("Could not connect to bridge focus service for a raise call: %1").arg(error));
}
if (!client.raise().ok()) {
@ -247,6 +227,7 @@ void focusOtherInstance() {
//****************************************************************************************************************************************************
/// \param [in] args list of arguments to pass to bridge.
/// \return bridge executable path
//****************************************************************************************************************************************************
const QString launchBridge(QStringList const &args) {
UPOverseer &overseer = app().bridgeOverseer();
@ -276,12 +257,8 @@ void closeBridgeApp() {
app().grpc().quit(); // this will cause the grpc service and the bridge app to close.
UPOverseer &overseer = app().bridgeOverseer();
if (!overseer) { // The app was run in 'attach' mode and attached to an existing instance of Bridge. We're not monitoring it.
return;
}
while (!overseer->isFinished()) {
QThread::msleep(20);
if (overseer) { // A null overseer means the app was run in 'attach' mode. We're not monitoring it.
overseer->wait(Overseer::maxTerminationWaitTimeMs);
}
}
@ -292,24 +269,23 @@ void closeBridgeApp() {
/// \return The exit code for the application.
//****************************************************************************************************************************************************
int main(int argc, char *argv[]) {
// Init sentry.
sentry_options_t *sentryOptions = newSentryOptions(PROJECT_DSN_SENTRY, sentryCacheDir().toStdString().c_str());
if (sentry_init(sentryOptions) != 0) {
std::cerr << "Failed to initialize sentry" << std::endl;
}
setSentryReportScope();
auto sentryClose = qScopeGuard([] { sentry_close(); });
// The application instance is needed to display system message boxes. As we may have to do it in the exception handler,
// application instance is create outside the try/catch clause.
if (QSysInfo::productType() != "windows") {
QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL);
QCoreApplication::setAttribute(Qt::AA_UseSoftwareOpenGL); // must be called before instantiating the BridgeApp
}
BridgeApp guiApp(argc, argv);
try {
QString const& configDir = bridgepp::userConfigDir();
// Init sentry.
initSentry();
auto sentryClose = qScopeGuard([] { sentry_close(); });
initQtApplication();
Log &log = initLog();
@ -339,14 +315,16 @@ int main(int argc, char *argv[]) {
}
// before launching bridge, we remove any trailing service config file, because we need to make sure we get a newly generated one.
GRPCClient::removeServiceConfigFile();
FocusGRPCClient::removeServiceConfigFile(configDir);
GRPCClient::removeServiceConfigFile(configDir);
bridgeexec = launchBridge(cliOptions.bridgeArgs);
}
log.info(QString("Retrieving gRPC service configuration from '%1'").arg(QDir::toNativeSeparators(grpcServerConfigPath())));
app().backend().init(GRPCClient::waitAndRetrieveServiceConfig(cliOptions.attach ? 0 : grpcServiceConfigWaitDelayMs, app().bridgeMonitor()));
log.info(QString("Retrieving gRPC service configuration from '%1'").arg(QDir::toNativeSeparators(grpcServerConfigPath(configDir))));
app().backend().init(GRPCClient::waitAndRetrieveServiceConfig(configDir, cliOptions.attach ? 0 : grpcServiceConfigWaitDelayMs,
app().bridgeMonitor()));
if (!cliOptions.attach) {
GRPCClient::removeServiceConfigFile();
GRPCClient::removeServiceConfigFile(configDir);
}
// gRPC communication is established. From now on, log events will be sent to bridge via gRPC. bridge will write these to file,
@ -359,7 +337,7 @@ int main(int argc, char *argv[]) {
// The following allows to render QML content in software with a 'Rendering Hardware Interface' (OpenGL, Vulkan, Metal, Direct3D...)
// Note that it is different from the Qt::AA_UseSoftwareOpenGL attribute we use on some platforms that instruct Qt that we would like
// to use a software-only implementation of OpenGL.
QQuickWindow::setSceneGraphBackend(cliOptions.useSoftwareRenderer ? "software" : "rhi");
QQuickWindow::setSceneGraphBackend((app().settings().useSoftwareRenderer() || cliOptions.useSoftwareRenderer) ? "software" : "rhi");
log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend()));
@ -412,7 +390,7 @@ int main(int argc, char *argv[]) {
QObject::disconnect(connection);
app().grpc().stopEventStreamReader();
if (!app().backend().waitForEventStreamReaderToFinish(5000)) {
if (!app().backend().waitForEventStreamReaderToFinish(Overseer::maxTerminationWaitTimeMs)) {
log.warn("Event stream reader took too long to finish.");
}

View File

@ -29,14 +29,19 @@ Item {
property var _spacing: 12 * ProtonStyle.px
property color usedSpaceColor : {
property color progressColor : {
if (!root.enabled) return root.colorScheme.text_weak
if (root.type == AccountDelegate.SmallView) return root.colorScheme.text_weak
if (root.usedFraction < .50) return root.colorScheme.signal_success
if (root.usedFraction < .75) return root.colorScheme.signal_warning
if (root.user && root.user.isSyncing) return root.colorScheme.text_weak
if (root.progressRatio < .50) return root.colorScheme.signal_success
if (root.progressRatio < .75) return root.colorScheme.signal_warning
return root.colorScheme.signal_danger
}
property real usedFraction: root.user ? reasonableFraction(root.user.usedBytes, root.user.totalBytes) : 0
property real progressRatio: {
if (!root.user)
return 0
return root.user.isSyncing ? root.user.syncProgress : reasonableFraction(root.user.usedBytes, root.user.totalBytes)
}
property string totalSpace: root.spaceWithUnits(root.user ? root.reasonableBytes(root.user.totalBytes) : 0)
property string usedSpace: root.spaceWithUnits(root.user ? root.reasonableBytes(root.user.usedBytes) : 0)
@ -171,18 +176,21 @@ Item {
case EUserState.Locked:
return qsTr("Connecting") + dotsTimer.dots
case EUserState.Connected:
return root.usedSpace
if (root.user.isSyncing)
return qsTr("Synchronizing (%1%)").arg(Math.floor(root.user.syncProgress * 100)) + dotsTimer.dots
else
return root.usedSpace
}
}
Timer { // dots animation while connecting. 1 sec cycle, roughly similar to the webmail loading page.
Timer { // dots animation while connecting & syncing.
id:dotsTimer
property string dots: ""
interval: 250;
interval: 500;
repeat: true;
running: (root.user != null) && (root.user.state === EUserState.Locked)
running: (root.user != null) && ((root.user.state === EUserState.Locked) || (root.user.isSyncing))
onTriggered: {
dots = dots + "."
dots += "."
if (dots.length > 3)
dots = ""
}
@ -191,7 +199,7 @@ Item {
}
}
color: root.usedSpaceColor
color: root.progressColor
type: {
switch (root.type) {
case AccountDelegate.SmallView: return Label.Caption
@ -202,7 +210,7 @@ Item {
Label {
colorScheme: root.colorScheme
text: root.user && root.user.state == EUserState.Connected ? " / " + root.totalSpace : ""
text: root.user && root.user.state == EUserState.Connected && !root.user.isSyncing ? " / " + root.totalSpace : ""
color: root.colorScheme.text_weak
type: {
switch (root.type) {
@ -213,26 +221,27 @@ Item {
}
}
Item { implicitHeight: root.type == AccountDelegate.LargeView ? 3 * ProtonStyle.px : 0 }
Rectangle {
id: storage_bar
id: progress_bar
visible: root.user ? root.type == AccountDelegate.LargeView : false
width: 140 * ProtonStyle.px
height: 4 * ProtonStyle.px
radius: ProtonStyle.storage_bar_radius
radius: ProtonStyle.progress_bar_radius
color: root.colorScheme.border_weak
Rectangle {
id: storage_bar_filled
radius: ProtonStyle.storage_bar_radius
color: root.usedSpaceColor
visible: root.user ? parent.visible && (root.user.state == EUserState.Connected) : false
id: progress_bar_filled
radius: ProtonStyle.progress_bar_radius
color: root.progressColor
visible: root.user ? parent.visible && (root.user.state == EUserState.Connected): false
anchors {
top : parent.top
bottom : parent.bottom
left : parent.left
}
width: Math.min(1,Math.max(0.02,root.usedFraction)) * parent.width
width: Math.min(1,Math.max(0.02,root.progressRatio)) * parent.width
}
}
}

View File

@ -29,6 +29,8 @@ Item {
property var notifications
signal showSetupGuide(var user, string address)
signal closeWindow()
signal quitBridge()
RowLayout {
anchors.fill: parent
@ -107,7 +109,7 @@ Item {
Layout.topMargin: 16
Layout.bottomMargin: 9
Layout.rightMargin: 16
Layout.rightMargin: 4
horizontalPadding: 0
@ -115,6 +117,55 @@ Item {
onClicked: rightContent.showGeneralSettings()
}
Button {
id: dotMenuButton
Layout.bottomMargin: 9
Layout.maximumHeight: 36
Layout.maximumWidth: 36
Layout.minimumHeight: 36
Layout.minimumWidth: 36
Layout.preferredHeight: 36
Layout.preferredWidth: 36
Layout.rightMargin: 16
Layout.topMargin: 16
colorScheme: leftBar.colorScheme
horizontalPadding: 0
icon.source: "/qml/icons/ic-three-dots-vertical.svg"
onClicked: {
dotMenu.open()
}
Menu {
id: dotMenu
colorScheme: root.colorScheme
modal: true
y: dotMenuButton.Layout.preferredHeight + dotMenuButton.Layout.bottomMargin
MenuItem {
colorScheme: root.colorScheme
text: qsTr("Close window")
onClicked: {
root.closeWindow()
}
}
MenuItem {
colorScheme: root.colorScheme
text: qsTr("Quit Bridge")
onClicked: {
root.quitBridge()
}
}
onClosed: {
parent.checked = false
}
onOpened: {
parent.checked = true
}
}
}
}
Item {implicitHeight:10}

View File

@ -142,6 +142,17 @@ ApplicationWindow {
onShowSetupGuide: function(user, address) {
root.showSetup(user,address)
}
onCloseWindow: {
root.close()
}
onQuitBridge: {
// If we ever want to add a confirmation dialog before quitting:
//root.notifications.askQuestion("Quit Bridge", "Insert warning message here.", "Quit", "Cancel", Backend.quit, null)
root.close()
Backend.quit()
}
}
WelcomeGuide { // 1

View File

@ -138,4 +138,9 @@ Item {
colorScheme: root.colorScheme
notification: root.notifications.genericError
}
NotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.genericQuestion
}
}

View File

@ -32,7 +32,7 @@ QtObject {
signal askResetBridge()
signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user)
signal askQuestion(var title, var description, var option1, var option2, var action1, var action2)
enum Group {
Connection = 1,
Update = 2,
@ -81,7 +81,9 @@ QtObject {
root.apiCertIssue,
root.noActiveKeyForRecipient,
root.userBadEvent,
root.genericError
root.imapLoginWhileSignedOut,
root.genericError,
root.genericQuestion,
]
// Connection
@ -1143,6 +1145,34 @@ QtObject {
}
property Notification imapLoginWhileSignedOut: Notification {
title: qsTr("IMAP Login failed")
brief: title
description: "#PlaceHolderText"
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
group: Notifications.Group.Connection
Connections {
target: Backend
function onImapLoginWhileSignedOut(username) {
root.imapLoginWhileSignedOut.description = qsTr("An email client tried to connect to the account %1, but this account is signed " +
"out. Please sign-in to continue.").arg(username)
root.imapLoginWhileSignedOut.active = true
}
}
action: [
Action {
text: qsTr("OK")
onTriggered: {
root.imapLoginWhileSignedOut.active = false
}
}
]
}
property Notification genericError: Notification {
title: "#PlaceholderText#"
description: "#PlaceholderText#"
@ -1168,4 +1198,50 @@ QtObject {
}
]
}
property Notification genericQuestion: Notification {
title: ""
brief: ""
description: ""
type: Notification.NotificationType.Warning
group: Notifications.Group.Dialogs
property var option1: ""
property var option2: ""
property variant action1: null
property variant action2: null
Connections {
target: root
function onAskQuestion(title, description, option1, option2, action1, action2) {
root.genericQuestion.title = title
root.genericQuestion.description = description
root.genericQuestion.option1 = option1
root.genericQuestion.option2 = option2
root.genericQuestion.action1 = action1
root.genericQuestion.action2 = action2
root.genericQuestion.active = true
}
}
action: [
Action {
text: root.genericQuestion.option1
onTriggered: {
root.genericQuestion.active = false
if (root.genericQuestion.action1)
root.genericQuestion.action1()
}
},
Action {
text: root.genericQuestion.option2
onTriggered: {
root.genericQuestion.active = false
if (root.genericQuestion.action2)
root.genericQuestion.action2()
}
}
]
}
}

View File

@ -26,13 +26,12 @@ T.MenuItem {
property ColorScheme colorScheme
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
width: parent.width // required. Other item overflows to the right of the menu and get clipped.
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
implicitIndicatorHeight + topPadding + bottomPadding)
padding: 6
padding: 12
spacing: 6
icon.width: 24

View File

@ -362,7 +362,7 @@ QtObject {
property real banner_radius : 12 * root.px // px
property real dialog_radius : 12 * root.px // px
property real card_radius : 12 * root.px // px
property real storage_bar_radius : 3 * root.px // px
property real progress_bar_radius : 3 * root.px // px
property real tooltip_radius : 8 * root.px // px
property int heading_font_size: 28

View File

@ -156,16 +156,6 @@ QString userDataDir() {
}
//****************************************************************************************************************************************************
/// \return user logs directory used by bridge.
//****************************************************************************************************************************************************
QString userLogsDir() {
QString const path = QDir(userDataDir()).absoluteFilePath("logs");
QDir().mkpath(path);
return path;
}
//****************************************************************************************************************************************************
/// \return sentry cache directory used by bridge.
//****************************************************************************************************************************************************

View File

@ -38,7 +38,7 @@ enum class OS {
QString userConfigDir(); ///< Get the path of the user configuration folder.
QString userCacheDir(); ///< Get the path of the user cache folder.
QString userLogsDir(); ///< Get the path of the user logs folder.
QString userDataDir(); ///< Get the path of the user data folder.
QString sentryCacheDir(); ///< Get the path of the sentry cache folder.
QString goos(); ///< return the value of Go's GOOS for the current platform ("darwin", "linux" and "windows" are supported).
qint64 randN(qint64 n); ///< return a random integer in the half open range [0,n)

View File

@ -29,7 +29,6 @@ namespace {
Empty empty; ///< Empty protobuf message, re-used across calls.
qint64 const port = 1042; ///< The port for the focus service.
QString const hostname = "127.0.0.1"; ///< The hostname of the focus service.
@ -39,12 +38,43 @@ QString const hostname = "127.0.0.1"; ///< The hostname of the focus service.
namespace bridgepp {
//****************************************************************************************************************************************************
/// \return the gRPC Focus server config file name
//****************************************************************************************************************************************************
QString grpcFocusServerConfigFilename() {
return "grpcFocusServerConfig.json";
}
//****************************************************************************************************************************************************
/// \return The absolute path of the focus service config path.
//****************************************************************************************************************************************************
QString FocusGRPCClient::grpcFocusServerConfigPath(QString const &configDir) {
return QDir(configDir).absoluteFilePath(grpcFocusServerConfigFilename());
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void FocusGRPCClient::removeServiceConfigFile(QString const &configDir) {
QString const path = grpcFocusServerConfigPath(configDir);
if (!QFile(path).exists()) {
return;
}
if (!QFile().remove(path)) {
throw Exception("Could not remove gRPC focus service config file.");
}
}
//****************************************************************************************************************************************************
/// \param[in] timeoutMs The timeout for the connexion.
/// \param[in] port The gRPC server port.
/// \param[out] outError if not null and the function returns false.
/// \return true iff the connexion was successfully established.
//****************************************************************************************************************************************************
bool FocusGRPCClient::connectToServer(qint64 timeoutMs, QString *outError) {
bool FocusGRPCClient::connectToServer(qint64 timeoutMs, quint16 port, QString *outError) {
try {
QString const address = QString("%1:%2").arg(hostname).arg(port);
channel_ = grpc::CreateChannel(address.toStdString(), grpc::InsecureChannelCredentials());

View File

@ -27,10 +27,14 @@
namespace bridgepp {
//**********************************************************************************************************************
//****************************************************************************************************************************************************
/// \brief Focus GRPC client class
//**********************************************************************************************************************
//****************************************************************************************************************************************************
class FocusGRPCClient {
public: // static member functions
static void removeServiceConfigFile(QString const &configDir); ///< Delete the service config file.
static QString grpcFocusServerConfigPath(QString const &configDir); ///< Return the path of the gRPC Focus server config file.
public: // member functions.
FocusGRPCClient() = default; ///< Default constructor.
FocusGRPCClient(FocusGRPCClient const &) = delete; ///< Disabled copy-constructor.
@ -38,8 +42,8 @@ public: // member functions.
~FocusGRPCClient() = default; ///< Destructor.
FocusGRPCClient &operator=(FocusGRPCClient const &) = delete; ///< Disabled assignment operator.
FocusGRPCClient &operator=(FocusGRPCClient &&) = delete; ///< Disabled move assignment operator.
bool connectToServer(qint64 timeoutMs, QString *outError = nullptr); ///< Connect to the focus server
bool connectToServer(qint64 timeoutMs, quint16 port, QString *outError = nullptr); ///< Connect to the focus server
grpc::Status raise(); ///< Performs the 'raise' call.
grpc::Status version(QString &outVersion); ///< Performs the 'version' call.

View File

@ -574,6 +574,78 @@ SPStreamEvent newUserBadEvent(QString const &userID, QString const &errorMessage
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \param[in] usedBytes The number of used bytes.
//****************************************************************************************************************************************************
SPStreamEvent newUsedBytesChangedEvent(QString const &userID, qint64 usedBytes) {
auto event = new grpc::UsedBytesChangedEvent;
event->set_userid(userID.toStdString());
event->set_usedbytes(usedBytes);
auto userEvent = new grpc::UserEvent;
userEvent->set_allocated_usedbyteschangedevent(event);
return wrapUserEvent(userEvent);
}
//****************************************************************************************************************************************************
/// \param[in] username The username that was provided for the failed IMAP login attempt.
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newIMAPLoginFailedEvent(QString const &username) {
auto event = new grpc::ImapLoginFailedEvent;
event->set_username(username.toStdString());
auto userEvent = new grpc::UserEvent;
userEvent->set_allocated_imaploginfailedevent(event);
return wrapUserEvent(userEvent);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newSyncStartedEvent(QString const &userID) {
auto event = new grpc::SyncStartedEvent;
event->set_userid(userID.toStdString());
auto userEvent = new grpc::UserEvent;
userEvent->set_allocated_syncstartedevent(event);
return wrapUserEvent(userEvent);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newSyncFinishedEvent(QString const &userID) {
auto event = new grpc::SyncFinishedEvent;
event->set_userid(userID.toStdString());
auto userEvent = new grpc::UserEvent;
userEvent->set_allocated_syncfinishedevent(event);
return wrapUserEvent(userEvent);
}
//****************************************************************************************************************************************************
/// \param[in] userID The userID.
/// \param[in] progress The progress ratio.
/// \param[in] elapsedMs The elapsed time in milliseconds.
/// \param[in] remainingMs The remaining time in milliseconds.
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newSyncProgressEvent(QString const &userID, double progress, qint64 elapsedMs, qint64 remainingMs) {
auto event = new grpc::SyncProgressEvent;
event->set_userid(userID.toStdString());
event->set_progress(progress);
event->set_elapsedms(elapsedMs);
event->set_remainingms(remainingMs);
auto userEvent = new grpc::UserEvent;
userEvent->set_allocated_syncprogressevent(event);
return wrapUserEvent(userEvent);
}
//****************************************************************************************************************************************************
/// \param[in] errorCode The error errorCode.
/// \return The event.

View File

@ -78,6 +78,11 @@ SPStreamEvent newToggleSplitModeFinishedEvent(QString const &userID); ///< Creat
SPStreamEvent newUserDisconnectedEvent(QString const &username); ///< Create a new UserDisconnectedEvent event.
SPStreamEvent newUserChangedEvent(QString const &userID); ///< Create a new UserChangedEvent event.
SPStreamEvent newUserBadEvent(QString const &userID, QString const& errorMessage); ///< Create a new UserBadEvent event.
SPStreamEvent newUsedBytesChangedEvent(QString const &userID, qint64 usedBytes); ///< Create a new UsedBytesChangedEvent event.
SPStreamEvent newIMAPLoginFailedEvent(QString const &username); ///< Create a new ImapLoginFailedEvent event.
SPStreamEvent newSyncStartedEvent(QString const &userID); ///< Create a new SyncStarted event.
SPStreamEvent newSyncFinishedEvent(QString const &userID); ///< Create a new SyncFinished event.
SPStreamEvent newSyncProgressEvent(QString const &userID, double progress, qint64 elapsedMs, qint64 remainingMs); ///< Create a new SyncFinished event.
// Generic error event
SPStreamEvent newGenericErrorEvent(grpc::ErrorCode errorCode); ///< Create a new GenericErrrorEvent event.

View File

@ -45,8 +45,8 @@ qint64 const grpcConnectionRetryDelayMs = 10000; ///< Retry delay for the gRPC c
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void GRPCClient::removeServiceConfigFile() {
QString const path = grpcServerConfigPath();
void GRPCClient::removeServiceConfigFile(QString const &configDir) {
QString const path = grpcServerConfigPath(configDir);
if (!QFile(path).exists()) {
return;
}
@ -61,8 +61,8 @@ void GRPCClient::removeServiceConfigFile() {
/// \param[in] serverProcess An optional server process to monitor. If the process it, no need and retry, as connexion cannot be established. Ignored if null.
/// \return The service config.
//****************************************************************************************************************************************************
GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(qint64 timeoutMs, ProcessMonitor *serverProcess) {
QString const path = grpcServerConfigPath();
GRPCConfig GRPCClient::waitAndRetrieveServiceConfig(QString const &configDir, qint64 timeoutMs, ProcessMonitor *serverProcess) {
QString const path = grpcServerConfigPath(configDir);
QFile file(path);
QElapsedTimer timer;
@ -109,7 +109,7 @@ void GRPCClient::setLog(Log *log) {
/// \param[in] serverProcess An optional server process to monitor. If the process it, no need and retry, as connexion cannot be established. Ignored if null.
/// \return true iff the connection was successful.
//****************************************************************************************************************************************************
void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serverProcess) {
void GRPCClient::connectToServer(QString const &configDir, GRPCConfig const &config, ProcessMonitor *serverProcess) {
try {
serverToken_ = config.token.toStdString();
QString address;
@ -147,8 +147,9 @@ void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
break;
} // connection established.
if (QDateTime::currentDateTime() > giveUpTime)
if (QDateTime::currentDateTime() > giveUpTime) {
throw Exception("Connection to the RPC server failed.");
}
}
if (channel_->GetState(true) != GRPC_CHANNEL_READY) {
@ -159,7 +160,7 @@ void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
QString const clientToken = QUuid::createUuid().toString();
QString error;
QString clientConfigPath = createClientConfigFile(clientToken, &error);
QString clientConfigPath = createClientConfigFile(configDir, clientToken, &error);
if (clientConfigPath.isEmpty()) {
throw Exception("gRPC client config could not be saved.", error);
}
@ -184,6 +185,14 @@ void GRPCClient::connectToServer(GRPCConfig const &config, ProcessMonitor *serve
}
//****************************************************************************************************************************************************
/// \return true if the gRPC client is connected to the server.
//****************************************************************************************************************************************************
bool GRPCClient::isConnected() const {
return stub_.get();
}
//****************************************************************************************************************************************************
/// \param[in] clientConfigPath The path to the gRPC client config path.-
/// \param[in] serverToken The token obtained from the server config file.
@ -223,8 +232,9 @@ grpc::Status GRPCClient::addLogEntry(Log::Level level, QString const &package, Q
grpc::Status GRPCClient::guiReady(bool &outShowSplashScreen) {
GuiReadyResponse response;
Status status = this->logGRPCCallStatus(stub_->GuiReady(this->clientContext().get(), empty, &response), __FUNCTION__);
if (status.ok())
if (status.ok()) {
outShowSplashScreen = response.showsplashscreen();
}
return status;
}
@ -395,6 +405,8 @@ grpc::Status GRPCClient::setIsDoHEnabled(bool enabled) {
//****************************************************************************************************************************************************
grpc::Status GRPCClient::quit() {
// quitting will shut down the gRPC service, to we may get an 'Unavailable' response for the call
if (!this->isConnected())
return Status::OK; // We're not even connected, we return OK. This maybe be an attempt to do 'a proper' shutdown after an unrecoverable error.
return this->logGRPCCallStatus(stub_->Quit(this->clientContext().get(), empty, &empty), __FUNCTION__, { StatusCode::UNAVAILABLE });
}
@ -458,15 +470,6 @@ grpc::Status GRPCClient::showOnStartup(bool &outValue) {
}
//****************************************************************************************************************************************************
/// \param[out] outGoos The value for the property.
/// \return The status for the gRPC call.
//****************************************************************************************************************************************************
grpc::Status GRPCClient::goos(QString &outGoos) {
return this->logGRPCCallStatus(this->getString(&Bridge::Stub::GoOs, outGoos), __FUNCTION__);
}
//****************************************************************************************************************************************************
/// \param[out] outPath The value for the property.
/// \return The status for the gRPC call.
@ -476,6 +479,15 @@ grpc::Status GRPCClient::logsPath(QUrl &outPath) {
}
//****************************************************************************************************************************************************
/// \param[out] outGoos The value for the property.
/// \return The status for the gRPC call.
//****************************************************************************************************************************************************
grpc::Status GRPCClient::goos(QString &outGoos) {
return this->logGRPCCallStatus(this->getString(&Bridge::Stub::GoOs, outGoos), __FUNCTION__);
}
//****************************************************************************************************************************************************
/// \param[out] outPath The value for the property.
/// \return The status for the gRPC call.
@ -1377,13 +1389,50 @@ void GRPCClient::processUserEvent(UserEvent const &event) {
break;
}
case UserEvent::kUserBadEvent: {
UserBadEvent const& e = event.userbadevent();
UserBadEvent const &e = event.userbadevent();
QString const userID = QString::fromStdString(e.userid());
QString const errorMessage = QString::fromStdString(e.errormessage());
this->logTrace(QString("User event received: UserBadEvent (userID = %1, errorMessage = %2).").arg(userID, errorMessage));
emit userBadEvent(userID, errorMessage);
break;
}
case UserEvent::kUsedBytesChangedEvent: {
UsedBytesChangedEvent const &e = event.usedbyteschangedevent();
QString const userID = QString::fromStdString(e.userid());
qint64 const usedBytes = e.usedbytes();
this->logTrace(QString("User event received: UsedBytesChangedEvent (userID = %1, usedBytes = %2).").arg(userID).arg(usedBytes));
emit usedBytesChanged(userID, usedBytes);
break;
}
case UserEvent::kImapLoginFailedEvent: {
ImapLoginFailedEvent const &e = event.imaploginfailedevent();
QString const username = QString::fromStdString(e.username());
this->logTrace(QString("User event received: IMAPLoginFailed (username = %1).:").arg(username));
emit imapLoginFailed(username);
break;
}
case UserEvent::kSyncStartedEvent: {
SyncStartedEvent const &e = event.syncstartedevent();
QString const &userID = QString::fromStdString(e.userid());
this->logTrace(QString("User event received: SyncStarted (userID = %1).:").arg(userID));
emit syncStarted(userID);
break;
}
case UserEvent::kSyncFinishedEvent: {
SyncFinishedEvent const &e = event.syncfinishedevent();
QString const &userID = QString::fromStdString(e.userid());
this->logTrace(QString("User event received: SyncFinished (userID = %1).:").arg(userID));
emit syncFinished(userID);
break;
}
case UserEvent::kSyncProgressEvent: {
SyncProgressEvent const &e = event.syncprogressevent();
QString const &userID = QString::fromStdString(e.userid());
this->logTrace(QString("User event received SyncProgress (userID = %1, progress = %2, elapsedMs = %3, remainingMs = %4).").arg(userID)
.arg(e.progress()).arg(e.elapsedms()).arg(e.remainingms()));
emit syncProgress(userID, e.progress(), e.elapsedms(), e.remainingms());
break;
}
default:
this->logError("Unknown User event received.");
}

View File

@ -48,8 +48,8 @@ typedef std::unique_ptr<grpc::ClientContext> UPClientContext;
class GRPCClient : public QObject {
Q_OBJECT
public: // static member functions
static void removeServiceConfigFile(); ///< Delete the service config file.
static GRPCConfig waitAndRetrieveServiceConfig(qint64 timeoutMs, class ProcessMonitor *serverProcess); ///< Wait and retrieve the service configuration.
static void removeServiceConfigFile(QString const &configDir); ///< Delete the service config file.
static GRPCConfig waitAndRetrieveServiceConfig(QString const &configDir, qint64 timeoutMs, class ProcessMonitor *serverProcess); ///< Wait and retrieve the service configuration.
public: // member functions.
GRPCClient() = default; ///< Default constructor.
@ -59,7 +59,8 @@ public: // member functions.
GRPCClient &operator=(GRPCClient const &) = delete; ///< Disabled assignment operator.
GRPCClient &operator=(GRPCClient &&) = delete; ///< Disabled move assignment operator.
void setLog(Log *log); ///< Set the log for the client.
void connectToServer(GRPCConfig const &config, class ProcessMonitor *serverProcess); ///< Establish connection to the gRPC server.
void connectToServer(QString const &configDir, GRPCConfig const &config, class ProcessMonitor *serverProcess); ///< Establish connection to the gRPC server.
bool isConnected() const; ///< Check whether the gRPC client is connected to the server.
grpc::Status checkTokens(QString const &clientConfigPath, QString &outReturnedClientToken); ///< Performs a token check.
grpc::Status addLogEntry(Log::Level level, QString const &package, QString const &message); ///< Performs the "AddLogEntry" gRPC call.
@ -180,6 +181,11 @@ signals:
void userDisconnected(QString const &username);
void userChanged(QString const &userID);
void userBadEvent(QString const &userID, QString const& errorMessage);
void usedBytesChanged(QString const &userID, qint64 usedBytes);
void imapLoginFailed(QString const& username);
void syncStarted(QString const &userID);
void syncFinished(QString const &userID);
void syncProgress(QString const &userID, double progress, qint64 elapsedMs, qint64 remainingMs);
public: // keychain related calls
grpc::Status availableKeychains(QStringList &outKeychains);

View File

@ -59,18 +59,19 @@ bool useFileSocketForGRPC() {
//****************************************************************************************************************************************************
/// \param[in] configDir The folder containing the configuration files.
/// \return The absolute path of the service config path.
//****************************************************************************************************************************************************
QString grpcServerConfigPath() {
return QDir(userConfigDir()).absoluteFilePath(grpcServerConfigFilename());
QString grpcServerConfigPath(QString const &configDir) {
return QDir(configDir).absoluteFilePath(grpcServerConfigFilename());
}
//****************************************************************************************************************************************************
/// \return The absolute path of the service config path.
//****************************************************************************************************************************************************
QString grpcClientConfigBasePath() {
return QDir(userConfigDir()).absoluteFilePath(grpcClientConfigBaseFilename());
QString grpcClientConfigBasePath(QString const &configDir) {
return QDir(configDir).absoluteFilePath(grpcClientConfigBaseFilename());
}
@ -81,8 +82,8 @@ QString grpcClientConfigBasePath() {
/// \return The path of the created file.
/// \return A null string if the file could not be saved.
//****************************************************************************************************************************************************
QString createClientConfigFile(QString const &token, QString *outError) {
QString const basePath = grpcClientConfigBasePath();
QString createClientConfigFile(QString const &configDir, QString const &token, QString *outError) {
QString const basePath = grpcClientConfigBasePath(configDir);
QString path, error;
for (qint32 i = 0; i < 1000; ++i) // we try a decent amount of times
{
@ -255,4 +256,4 @@ QString getAvailableFileSocketPath() {
}
} // namespace bridgepp
} // namespace bridgepp

View File

@ -34,9 +34,9 @@ extern std::string const grpcMetadataServerTokenKey; ///< The key for the server
typedef std::shared_ptr<grpc::StreamEvent> SPStreamEvent; ///< Type definition for shared pointer to grpc::StreamEvent.
QString grpcServerConfigPath(); ///< Return the path of the gRPC server config file.
QString grpcClientConfigBasePath(); ///< Return the path of the gRPC client config file.
QString createClientConfigFile(QString const &token, QString *outError); ///< Create the client config file the server will retrieve and return its path.
QString grpcServerConfigPath(QString const &configDir); ///< Return the path of the gRPC server config file.
QString grpcClientConfigBasePath(QString const &configDir); ///< Return the path of the gRPC client config file.
QString createClientConfigFile(QString const &configDir, QString const &token, QString *outError); ///< Create the client config file the server will retrieve and return its path.
grpc::LogLevel logLevelToGRPC(Log::Level level); ///< Convert a Log::Level to gRPC enum value.
Log::Level logLevelFromGRPC(grpc::LogLevel level); ///< Convert a grpc::LogLevel to a Log::Level.
grpc::UserState userStateToGRPC(UserState state); ///< Convert a bridgepp::UserState to a grpc::UserState.

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,8 @@ SPUser User::newUser(QObject *parent) {
/// \param[in] parent The parent object.
//****************************************************************************************************************************************************
User::User(QObject *parent)
: QObject(parent) {
: QObject(parent)
, imapFailureCooldownEndTime_(QDateTime::currentDateTime()) {
}
@ -293,6 +294,48 @@ void User::setTotalBytes(float totalBytes) {
}
//****************************************************************************************************************************************************
/// \return true iff a sync is in progress.
//****************************************************************************************************************************************************
bool User::isSyncing() const {
return isSyncing_;
}
//****************************************************************************************************************************************************
/// \param[in] syncing The new value for the sync state.
//****************************************************************************************************************************************************
void User::setIsSyncing(bool syncing) {
if (isSyncing_ == syncing) {
return;
}
isSyncing_ = syncing;
emit isSyncingChanged(syncing);
}
//****************************************************************************************************************************************************
/// \return The sync progress ratio
//****************************************************************************************************************************************************
float User::syncProgress() const {
return syncProgress_;
}
//****************************************************************************************************************************************************
/// \param[in] progress The progress ratio.
//****************************************************************************************************************************************************
void User::setSyncProgress(float progress) {
if (qAbs(syncProgress_ - progress) < 0.00001) {
return;
}
syncProgress_ = progress;
emit syncProgressChanged(progress);
}
//****************************************************************************************************************************************************
/// \param[in] state The user state.
/// \return A string describing the state.
@ -311,4 +354,24 @@ QString User::stateToString(UserState state) {
}
//****************************************************************************************************************************************************
/// We display a notification and pop the application window if an IMAP client tries to connect to a signed out account, but we do not want to
/// do it repeatedly, as it's an intrusive action. This function let's you define a period of time during which the notification should not be
/// displayed.
///
/// \param durationMSecs The duration of the period in milliseconds.
//****************************************************************************************************************************************************
void User::startImapLoginFailureCooldown(qint64 durationMSecs) {
imapFailureCooldownEndTime_ = QDateTime::currentDateTime().addMSecs(durationMSecs);
}
//****************************************************************************************************************************************************
/// \return true if we currently are in a cooldown period for the notification
//****************************************************************************************************************************************************
bool User::isInIMAPLoginFailureCooldown() const {
return QDateTime::currentDateTime() < imapFailureCooldownEndTime_;
}
} // namespace bridgepp

View File

@ -74,6 +74,8 @@ public: // member functions.
User &operator=(User &&) = delete; ///< Disabled move assignment operator.
void update(User const &user); ///< Update the user.
Q_INVOKABLE QString primaryEmailOrUsername() const; ///< Return the user primary email, or, if unknown its username.
void startImapLoginFailureCooldown(qint64 durationMSecs); ///< Start the user cooldown period for the IMAP login attempt while signed-out notification.
bool isInIMAPLoginFailureCooldown() const; ///< Check if the user in a IMAP login failure notification.
public slots:
// slots for QML generated calls
@ -99,6 +101,8 @@ public:
Q_PROPERTY(bool splitMode READ splitMode WRITE setSplitMode NOTIFY splitModeChanged)
Q_PROPERTY(float usedBytes READ usedBytes WRITE setUsedBytes NOTIFY usedBytesChanged)
Q_PROPERTY(float totalBytes READ totalBytes WRITE setTotalBytes NOTIFY totalBytesChanged)
Q_PROPERTY(bool isSyncing READ isSyncing WRITE setIsSyncing NOTIFY isSyncingChanged)
Q_PROPERTY(float syncProgress READ syncProgress WRITE setSyncProgress NOTIFY syncProgressChanged)
QString id() const;
void setID(QString const &id);
@ -118,6 +122,10 @@ public:
void setUsedBytes(float usedBytes);
float totalBytes() const;
void setTotalBytes(float totalBytes);
bool isSyncing() const;
void setIsSyncing(bool syncing);
float syncProgress() const;
void setSyncProgress(float progress);
signals:
// signals used for Qt properties
@ -132,11 +140,14 @@ signals:
void usedBytesChanged(float byteCount);
void totalBytesChanged(float byteCount);
void toggleSplitModeFinished();
void isSyncingChanged(bool syncing);
void syncProgressChanged(float syncProgress);
private: // member functions.
User(QObject *parent); ///< Default constructor.
private: // data members.
QDateTime imapFailureCooldownEndTime_; ///< The end date/time for the IMAP login failure notification cooldown period.
QString id_; ///< The userID.
QString username_; ///< The username
QString password_; ///< The IMAP password of the user.
@ -146,6 +157,8 @@ private: // data members.
bool splitMode_ { false }; ///< Is split mode active.
float usedBytes_ { 0.0f }; ///< The storage used by the user.
float totalBytes_ { 1.0f }; ///< The storage quota of the user.
bool isSyncing_ { false }; ///< Is a sync in progress for the user.
float syncProgress_ { 0.0f }; ///< The sync progress.
};

View File

@ -84,7 +84,8 @@ void Overseer::releaseWorker() {
if (thread_) {
if (!thread_->isFinished()) {
thread_->quit();
thread_->wait();
if (!thread_->wait(maxTerminationWaitTimeMs))
thread_->terminate();
}
thread_->deleteLater();
thread_ = nullptr;

View File

@ -46,6 +46,9 @@ public slots:
void startWorker(bool autorelease) const; ///< Run the worker.
void releaseWorker(); ///< Delete the worker and its thread.
public: // static data members
static qint64 const maxTerminationWaitTimeMs { 10000 }; ///< The maximum wait time for the termination of a thread
public: // data members.
QThread *thread_ { nullptr }; ///< The thread.
Worker *worker_ { nullptr }; ///< The worker.

View File

@ -115,7 +115,7 @@ func (f *frontendCLI) showAccountAddressInfo(user bridge.UserInfo, address strin
f.Println("")
}
func (f *frontendCLI) loginAccount(c *ishell.Context) { //nolint:funlen
func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.ShowPrompt(false)
defer f.ShowPrompt(true)

View File

@ -42,7 +42,7 @@ type frontendCLI struct {
}
// New returns a new CLI frontend configured with the given options.
func New(bridge *bridge.Bridge, restarter *restarter.Restarter, eventCh <-chan events.Event) *frontendCLI { //nolint:funlen,revive
func New(bridge *bridge.Bridge, restarter *restarter.Restarter, eventCh <-chan events.Event) *frontendCLI { //nolint:revive
fe := &frontendCLI{
Shell: ishell.New(),
bridge: bridge,
@ -138,12 +138,16 @@ func New(bridge *bridge.Bridge, restarter *restarter.Restarter, eventCh <-chan e
fe.AddCmd(configureCmd)
// TLS commands.
exportTLSCmd := &ishell.Cmd{
Name: "export-tls",
fe.AddCmd(&ishell.Cmd{
Name: "export-tls-cert",
Help: "Export the TLS certificate used by the Bridge",
Func: fe.exportTLSCerts,
}
fe.AddCmd(exportTLSCmd)
})
fe.AddCmd(&ishell.Cmd{
Name: "import-tls-cert",
Help: "Import a TLS certificate to be used by the Bridge",
Func: fe.importTLSCerts,
})
// All mail visibility commands.
allMailCmd := &ishell.Cmd{
@ -280,7 +284,7 @@ func New(bridge *bridge.Bridge, restarter *restarter.Restarter, eventCh <-chan e
return fe
}
func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:funlen
func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:gocyclo
// GODT-1949: Better error events.
for _, err := range f.bridge.GetErrors() {
switch {
@ -289,12 +293,6 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:funle
case errors.Is(err, bridge.ErrVaultInsecure):
f.notifyCredentialsError()
case errors.Is(err, bridge.ErrServeIMAP):
f.Println("IMAP server error:", err)
case errors.Is(err, bridge.ErrServeSMTP):
f.Println("SMTP server error:", err)
}
}
@ -306,6 +304,12 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:funle
case events.ConnStatusDown:
f.notifyInternetOff()
case events.IMAPServerError:
f.Println("IMAP server error:", event.Error)
case events.SMTPServerError:
f.Println("SMTP server error:", event.Error)
case events.UserDeauth:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
@ -331,6 +335,17 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:funle
f.Printf("* bad-event synchronize\n")
f.Printf("* bad-event logout\n\n")
case events.IMAPLoginFailed:
f.Printf("An IMAP login attempt failed for user %v\n", event.Username)
case events.UserAddressEnabled:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
return
}
f.Printf("An address for %s was enabled. You may need to reconfigure your email client.\n", user.Username)
case events.UserAddressUpdated:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
@ -339,8 +354,21 @@ func (f *frontendCLI) watchEvents(eventCh <-chan events.Event) { // nolint:funle
f.Printf("Address changed for %s. You may need to reconfigure your email client.\n", user.Username)
case events.UserAddressDisabled:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
return
}
f.Printf("An address for %s was disabled. You may need to reconfigure your email client.\n", user.Username)
case events.UserAddressDeleted:
f.notifyLogout(event.Email)
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
return
}
f.Printf("An address for %s was disabled. You may need to reconfigure your email client.\n", user.Username)
case events.SyncStarted:
user, err := f.bridge.GetUserInfo(event.UserID)

View File

@ -226,6 +226,27 @@ func (f *frontendCLI) exportTLSCerts(c *ishell.Context) {
}
}
func (f *frontendCLI) importTLSCerts(c *ishell.Context) {
certPath := f.readStringInAttempts("Enter the path to the cert.pem file", c.ReadLine, f.isFile)
if certPath == "" {
f.printAndLogError(errors.New("failed to get cert path"))
return
}
keyPath := f.readStringInAttempts("Enter the path to the key.pem file", c.ReadLine, f.isFile)
if keyPath == "" {
f.printAndLogError(errors.New("failed to get key path"))
return
}
if err := f.bridge.SetBridgeTLSCertPath(certPath, keyPath); err != nil {
f.printAndLogError(err)
return
}
f.Println("TLS certificate imported. Restart Bridge to use it.")
}
func (f *frontendCLI) isPortFree(port string) bool {
port = strings.ReplaceAll(port, ":", "")
if port == "" {
@ -252,3 +273,12 @@ func (f *frontendCLI) isCacheLocationUsable(location string) bool {
return stat.IsDir()
}
func (f *frontendCLI) isFile(location string) bool {
stat, err := os.Stat(location)
if err != nil {
return false
}
return !stat.IsDir()
}

File diff suppressed because it is too large Load Diff

View File

@ -455,6 +455,11 @@ message UserEvent {
UserDisconnectedEvent userDisconnected = 2;
UserChangedEvent userChanged = 3;
UserBadEvent userBadEvent = 4;
UsedBytesChangedEvent usedBytesChangedEvent = 5;
ImapLoginFailedEvent imapLoginFailedEvent = 6;
SyncStartedEvent syncStartedEvent = 7;
SyncFinishedEvent syncFinishedEvent = 8;
SyncProgressEvent syncProgressEvent = 9;
}
}
@ -475,6 +480,30 @@ message UserBadEvent {
string errorMessage = 2;
}
message UsedBytesChangedEvent {
string userID = 1;
int64 usedBytes = 2;
}
message ImapLoginFailedEvent {
string username = 1;
}
message SyncStartedEvent {
string userID = 1;
}
message SyncFinishedEvent {
string userID = 1;
}
message SyncProgressEvent {
string userID = 1;
double progress = 2;
int64 elapsedMs = 3;
int64 remainingMs = 4;
}
//**********************************************************
// Generic errors
//**********************************************************

View File

@ -177,6 +177,31 @@ func NewUserBadEvent(userID string, errorMessage string) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_UserBadEvent{UserBadEvent: &UserBadEvent{UserID: userID, ErrorMessage: errorMessage}}})
}
func NewUsedBytesChangedEvent(userID string, usedBytes int) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_UsedBytesChangedEvent{UsedBytesChangedEvent: &UsedBytesChangedEvent{UserID: userID, UsedBytes: int64(usedBytes)}}})
}
func newIMAPLoginFailedEvent(username string) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_ImapLoginFailedEvent{ImapLoginFailedEvent: &ImapLoginFailedEvent{Username: username}}})
}
func NewSyncStartedEvent(userID string) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_SyncStartedEvent{SyncStartedEvent: &SyncStartedEvent{UserID: userID}}})
}
func NewSyncFinishedEvent(userID string) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_SyncFinishedEvent{SyncFinishedEvent: &SyncFinishedEvent{UserID: userID}}})
}
func NewSyncProgressEvent(userID string, progress float64, elapsedMs, remainingMs int64) *StreamEvent {
return userEvent(&UserEvent{Event: &UserEvent_SyncProgressEvent{SyncProgressEvent: &SyncProgressEvent{
UserID: userID,
Progress: progress,
ElapsedMs: elapsedMs,
RemainingMs: remainingMs,
}}})
}
func NewGenericErrorEvent(errorCode ErrorCode) *StreamEvent {
return genericErrorEvent(&GenericErrorEvent{Code: errorCode})
}

View File

@ -25,6 +25,7 @@ import (
"errors"
"fmt"
"io/fs"
"math/rand"
"net"
"os"
"path/filepath"
@ -37,6 +38,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/bradenaw/juniper/xslices"
"github.com/elastic/go-sysinfo"
@ -93,12 +95,10 @@ type Service struct { // nolint:structcheck
}
// NewService returns a new instance of the service.
//
// nolint:funlen
func NewService(
panicHandler CrashHandler,
restarter Restarter,
locations Locator,
locations service.Locator,
bridge *bridge.Bridge,
eventCh <-chan events.Event,
quitCh <-chan struct{},
@ -110,7 +110,7 @@ func NewService(
logrus.WithError(err).Panic("Could not generate gRPC TLS config")
}
config := Config{
config := service.Config{
Cert: string(certPEM),
Token: uuid.NewString(),
}
@ -141,7 +141,7 @@ func NewService(
config.Port = address.Port
}
if path, err := saveGRPCServerConfigFile(locations, &config); err != nil {
if path, err := service.SaveGRPCServerConfigFile(locations, &config, serverConfigFileName); err != nil {
logrus.WithError(err).WithField("path", path).Panic("Could not write gRPC service config file")
} else {
logrus.WithField("path", path).Info("Successfully saved gRPC service config file")
@ -245,7 +245,7 @@ func (s *Service) WaitUntilFrontendIsReady() {
s.initializing.Wait()
}
// nolint:funlen,gocyclo
// nolint:gocyclo
func (s *Service) watchEvents() {
// GODT-1949 Better error events.
for _, err := range s.bridge.GetErrors() {
@ -255,12 +255,6 @@ func (s *Service) watchEvents() {
case errors.Is(err, bridge.ErrVaultInsecure):
_ = s.SendEvent(NewKeychainHasNoKeychainEvent())
case errors.Is(err, bridge.ErrServeIMAP):
_ = s.SendEvent(NewMailServerSettingsErrorEvent(MailServerSettingsErrorType_IMAP_PORT_STARTUP_ERROR))
case errors.Is(err, bridge.ErrServeSMTP):
_ = s.SendEvent(NewMailServerSettingsErrorEvent(MailServerSettingsErrorType_SMTP_PORT_STARTUP_ERROR))
}
}
@ -272,6 +266,12 @@ func (s *Service) watchEvents() {
case events.ConnStatusDown:
_ = s.SendEvent(NewInternetStatusEvent(false))
case events.IMAPServerError:
_ = s.SendEvent(NewMailServerSettingsErrorEvent(MailServerSettingsErrorType_IMAP_PORT_STARTUP_ERROR))
case events.SMTPServerError:
_ = s.SendEvent(NewMailServerSettingsErrorEvent(MailServerSettingsErrorType_SMTP_PORT_STARTUP_ERROR))
case events.Raise:
_ = s.SendEvent(NewShowMainWindowEvent())
@ -305,6 +305,12 @@ func (s *Service) watchEvents() {
case events.AddressModeChanged:
_ = s.SendEvent(NewUserChangedEvent(event.UserID))
case events.UsedSpaceChanged:
_ = s.SendEvent(NewUsedBytesChangedEvent(event.UserID, event.UsedSpace))
case events.IMAPLoginFailed:
_ = s.SendEvent(newIMAPLoginFailedEvent(event.Username))
case events.UserDeauth:
// This is the event the GUI cares about.
_ = s.SendEvent(NewUserChangedEvent(event.UserID))
@ -317,6 +323,15 @@ func (s *Service) watchEvents() {
case events.UserBadEvent:
_ = s.SendEvent(NewUserBadEvent(event.UserID, event.Error.Error()))
case events.SyncStarted:
_ = s.SendEvent(NewSyncStartedEvent(event.UserID))
case events.SyncFinished:
_ = s.SendEvent(NewSyncFinishedEvent(event.UserID))
case events.SyncProgress:
_ = s.SendEvent(NewSyncProgressEvent(event.UserID, event.Progress, event.Elapsed.Milliseconds(), event.Remaining.Milliseconds()))
case events.UpdateLatest:
safe.RLock(func() {
s.latest = event.Version
@ -481,17 +496,6 @@ func newTLSConfig() (*tls.Config, []byte, error) {
}, certPEM, nil
}
func saveGRPCServerConfigFile(locations Locator, config *Config) (string, error) {
settingsPath, err := locations.ProvideSettingsPath()
if err != nil {
return "", err
}
configPath := filepath.Join(settingsPath, serverConfigFileName)
return configPath, config.save(configPath)
}
// validateServerToken verify that the server token provided by the client is valid.
func validateServerToken(ctx context.Context, wantToken string) error {
values, ok := metadata.FromIncomingContext(ctx)
@ -577,10 +581,17 @@ func (s *Service) monitorParentPID() {
func computeFileSocketPath() (string, error) {
tempPath := os.TempDir()
for i := 0; i < 1000; i++ {
path := filepath.Join(tempPath, fmt.Sprintf("bridge_%v.sock", uuid.NewString()))
path := filepath.Join(tempPath, fmt.Sprintf("bridge%04d", rand.Intn(10000))) // nolint:gosec
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return path, nil
}
if err := os.Remove(path); err != nil {
logrus.WithField("path", path).WithError(err).Warning("Could not remove existing socket file")
continue
}
return path, nil
}
return "", errors.New("unable to find a suitable file socket in user config folder")

View File

@ -32,6 +32,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/frontend/theme"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/ProtonMail/proton-bridge/v3/pkg/ports"
@ -51,8 +52,8 @@ func (s *Service) CheckTokens(ctx context.Context, clientConfigPath *wrapperspb.
path := clientConfigPath.Value
logEntry := s.log.WithField("path", path)
var clientConfig Config
if err := clientConfig.load(path); err != nil {
var clientConfig service.Config
if err := clientConfig.Load(path); err != nil {
logEntry.WithError(err).Error("Could not read gRPC client config file")
return nil, err

View File

@ -110,7 +110,7 @@ func (s *Service) SendEvent(event *StreamEvent) error {
}
// StartEventTest sends all the known event via gRPC.
func (s *Service) StartEventTest() error { //nolint:funlen
func (s *Service) StartEventTest() error {
const dummyAddress = "dummy@proton.me"
events := []*StreamEvent{
// app
@ -174,6 +174,7 @@ func (s *Service) StartEventTest() error { //nolint:funlen
NewUserToggleSplitModeFinishedEvent("userID"),
NewUserDisconnectedEvent("username"),
NewUserChangedEvent("userID"),
NewUsedBytesChangedEvent("userID", 1000),
}
for _, event := range events {

View File

@ -26,7 +26,3 @@ type Restarter interface {
AddFlags(flags ...string)
Override(exe string)
}
type Locator interface {
ProvideSettingsPath() (string, error)
}

View File

@ -217,14 +217,13 @@ func (l *Locations) getUpdatesPath() string {
}
// Clear removes everything except the lock and update files.
func (l *Locations) Clear() error {
func (l *Locations) Clear(except ...string) error {
return files.Remove(
l.userConfig,
l.userData,
l.userCache,
).Except(
l.GetGuiLockFile(),
l.getUpdatesPath(),
append(except, l.GetGuiLockFile(), l.getUpdatesPath())...,
).Do()
}

View File

@ -15,11 +15,12 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package grpc
package service
import (
"encoding/json"
"os"
"path/filepath"
)
// Config is a structure containing the service configuration data that are exchanged by the gRPC server and client.
@ -53,8 +54,8 @@ func (s *Config) _save(path string) error {
return json.NewEncoder(f).Encode(s)
}
// load loads a gRPC service configuration from file.
func (s *Config) load(path string) error {
// Load loads a gRPC service configuration from file.
func (s *Config) Load(path string) error {
f, err := os.Open(path) //nolint:errcheck,gosec
if err != nil {
return err
@ -64,3 +65,15 @@ func (s *Config) load(path string) error {
return json.NewDecoder(f).Decode(s)
}
// SaveGRPCServerConfigFile save GRPC configuration file.
func SaveGRPCServerConfigFile(locations Locator, config *Config, filename string) (string, error) {
settingsPath, err := locations.ProvideSettingsPath()
if err != nil {
return "", err
}
configPath := filepath.Join(settingsPath, filename)
return configPath, config.save(configPath)
}

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package grpc
package service
import (
"path/filepath"
@ -46,11 +46,11 @@ func TestConfig(t *testing.T) {
require.NoError(t, conf1.save(tempFilePath))
conf2 := Config{}
require.NoError(t, conf2.load(tempFilePath))
require.NoError(t, conf2.Load(tempFilePath))
require.Equal(t, conf1, conf2)
// failure to load
require.Error(t, conf2.load(tempFilePath+"_"))
require.Error(t, conf2.Load(tempFilePath+"_"))
// failure to save
require.Error(t, conf2.save(filepath.Join(tempDir, "non/existing/folder", tempFileName)))

22
internal/service/types.go Normal file
View File

@ -0,0 +1,22 @@
// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.Bridge.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package service
type Locator interface {
ProvideSettingsPath() (string, error)
}

View File

@ -142,7 +142,7 @@ func checksum(path string) (hash string) {
// srcDir including app folder.
// dstDir including app folder.
func copyRecursively(srcDir, dstDir string) error { //nolint:funlen
func copyRecursively(srcDir, dstDir string) error {
return filepath.Walk(srcDir, func(srcPath string, srcInfo os.FileInfo, err error) error {
if err != nil {
return err

View File

@ -18,6 +18,7 @@
package user
import (
"bytes"
"context"
"errors"
"fmt"
@ -67,6 +68,10 @@ func (user *User) handleAPIEvent(ctx context.Context, event proton.Event) error
}
}
if event.UsedSpace != nil {
user.handleUsedSpaceChange(*event.UsedSpace)
}
return nil
}
@ -194,6 +199,12 @@ func (user *User) handleCreateAddressEvent(ctx context.Context, event proton.Add
user.apiAddrs[event.Address.ID] = event.Address
// If the address is disabled.
if event.Address.Status != proton.AddressStatusEnabled {
return nil
}
// If the address is enabled, we need to hook it up to the update channels.
switch user.vault.AddressMode() {
case vault.CombinedMode:
primAddr, err := getAddrIdx(user.apiAddrs, 0)
@ -220,6 +231,10 @@ func (user *User) handleCreateAddressEvent(ctx context.Context, event proton.Add
// Perform the sync in an RLock.
return safe.RLockRet(func() error {
if event.Address.Status != proton.AddressStatusEnabled {
return nil
}
if user.vault.AddressMode() == vault.SplitMode {
if err := syncLabels(ctx, user.apiLabels, user.updateCh[event.Address.ID]); err != nil {
return fmt.Errorf("failed to sync labels to new address: %w", err)
@ -237,18 +252,58 @@ func (user *User) handleUpdateAddressEvent(_ context.Context, event proton.Addre
"email": logging.Sensitive(event.Address.Email),
}).Info("Handling address updated event")
if _, ok := user.apiAddrs[event.Address.ID]; !ok {
oldAddr, ok := user.apiAddrs[event.Address.ID]
if !ok {
user.log.Debugf("Address %q does not exist", event.Address.ID)
return nil
}
user.apiAddrs[event.Address.ID] = event.Address
user.eventCh.Enqueue(events.UserAddressUpdated{
UserID: user.apiUser.ID,
AddressID: event.Address.ID,
Email: event.Address.Email,
})
switch {
// If the address was newly enabled:
case oldAddr.Status != proton.AddressStatusEnabled && event.Address.Status == proton.AddressStatusEnabled:
switch user.vault.AddressMode() {
case vault.CombinedMode:
primAddr, err := getAddrIdx(user.apiAddrs, 0)
if err != nil {
return fmt.Errorf("failed to get primary address: %w", err)
}
user.updateCh[event.Address.ID] = user.updateCh[primAddr.ID]
case vault.SplitMode:
user.updateCh[event.Address.ID] = queue.NewQueuedChannel[imap.Update](0, 0)
}
user.eventCh.Enqueue(events.UserAddressEnabled{
UserID: user.apiUser.ID,
AddressID: event.Address.ID,
Email: event.Address.Email,
})
// If the address was newly disabled:
case oldAddr.Status == proton.AddressStatusEnabled && event.Address.Status != proton.AddressStatusEnabled:
if user.vault.AddressMode() == vault.SplitMode {
user.updateCh[event.ID].CloseAndDiscardQueued()
}
delete(user.updateCh, event.ID)
user.eventCh.Enqueue(events.UserAddressDisabled{
UserID: user.apiUser.ID,
AddressID: event.Address.ID,
Email: event.Address.Email,
})
// Otherwise it's just an update:
default:
user.eventCh.Enqueue(events.UserAddressUpdated{
UserID: user.apiUser.ID,
AddressID: event.Address.ID,
Email: event.Address.Email,
})
}
return nil
}, user.apiAddrsLock, user.updateChLock)
@ -264,12 +319,20 @@ func (user *User) handleDeleteAddressEvent(_ context.Context, event proton.Addre
return nil
}
if user.vault.AddressMode() == vault.SplitMode {
user.updateCh[event.ID].CloseAndDiscardQueued()
delete(user.updateCh, event.ID)
delete(user.apiAddrs, event.ID)
// If the address was disabled to begin with, we don't need to do anything.
if addr.Status != proton.AddressStatusEnabled {
return nil
}
delete(user.apiAddrs, event.ID)
// Otherwise, in split mode, drop the update queue.
if user.vault.AddressMode() == vault.SplitMode {
user.updateCh[event.ID].CloseAndDiscardQueued()
}
// And in either mode, remove the address from the update channel map.
delete(user.updateCh, event.ID)
user.eventCh.Enqueue(events.UserAddressDeleted{
UserID: user.apiUser.ID,
@ -356,25 +419,51 @@ func (user *User) handleUpdateLabelEvent(ctx context.Context, event proton.Label
"name": logging.Sensitive(event.Label.Name),
}).Info("Handling label updated event")
// Only update the label if it exists; we don't want to create it as a client may have just deleted it.
if _, ok := user.apiLabels[event.Label.ID]; ok {
user.apiLabels[event.Label.ID] = event.Label
}
stack := []proton.Label{event.Label}
for _, updateCh := range xslices.Unique(maps.Values(user.updateCh)) {
update := imap.NewMailboxUpdated(
imap.MailboxID(event.ID),
getMailboxName(event.Label),
)
updateCh.Enqueue(update)
updates = append(updates, update)
}
for len(stack) > 0 {
label := stack[0]
stack = stack[1:]
user.eventCh.Enqueue(events.UserLabelUpdated{
UserID: user.apiUser.ID,
LabelID: event.Label.ID,
Name: event.Label.Name,
})
// Only update the label if it exists; we don't want to create it as a client may have just deleted it.
if _, ok := user.apiLabels[label.ID]; ok {
user.apiLabels[label.ID] = event.Label
}
// API doesn't notify us that the path has changed. We need to fetch it again.
apiLabel, err := user.client.GetLabel(ctx, label.ID, label.Type)
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
user.log.WithError(apiErr).Warn("Failed to get label: label does not exist")
continue
} else if err != nil {
return nil, fmt.Errorf("failed to get label %q: %w", label.ID, err)
}
// Update the label in the map.
user.apiLabels[apiLabel.ID] = apiLabel
// Notify the IMAP clients.
for _, updateCh := range xslices.Unique(maps.Values(user.updateCh)) {
update := imap.NewMailboxUpdated(
imap.MailboxID(apiLabel.ID),
getMailboxName(apiLabel),
)
updateCh.Enqueue(update)
updates = append(updates, update)
}
user.eventCh.Enqueue(events.UserLabelUpdated{
UserID: user.apiUser.ID,
LabelID: apiLabel.ID,
Name: apiLabel.Name,
})
children := xslices.Filter(maps.Values(user.apiLabels), func(other proton.Label) bool {
return other.ParentID == label.ID
})
stack = append(stack, children...)
}
return updates, nil
}, user.apiLabelsLock, user.updateChLock)
@ -404,7 +493,7 @@ func (user *User) handleDeleteLabelEvent(ctx context.Context, event proton.Label
}
// handleMessageEvents handles the given message events.
func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proton.MessageEvent) error { //nolint:funlen
func (user *User) handleMessageEvents(ctx context.Context, messageEvents []proton.MessageEvent) error {
for _, event := range messageEvents {
ctx = logging.WithLogrusField(ctx, "messageID", event.ID)
@ -494,7 +583,7 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, message proton.M
"subject": logging.Sensitive(message.Subject),
}).Info("Handling message created event")
full, err := user.client.GetFullMessage(ctx, message.ID)
full, err := user.client.GetFullMessage(ctx, message.ID, newProtonAPIScheduler(), proton.NewDefaultAttachmentAllocator())
if err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
@ -509,7 +598,7 @@ func (user *User) handleCreateMessageEvent(ctx context.Context, message proton.M
var update imap.Update
if err := withAddrKR(user.apiUser, user.apiAddrs[message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
res := buildRFC822(user.apiLabels, full, addrKR)
res := buildRFC822(user.apiLabels, full, addrKR, new(bytes.Buffer))
if res.err != nil {
user.log.WithError(err).Error("Failed to build RFC822 message")
@ -553,7 +642,7 @@ func (user *User) handleUpdateMessageEvent(ctx context.Context, message proton.M
Seen: message.Seen(),
Flagged: message.Starred(),
Draft: message.IsDraft(),
Answered: message.IsReplied == true || message.IsRepliedAll == true, //nolint: gosimple
Answered: message.IsRepliedAll == true || message.IsReplied == true, //nolint: gosimple
},
)
@ -586,7 +675,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
"subject": logging.Sensitive(event.Message.Subject),
}).Info("Handling draft updated event")
full, err := user.client.GetFullMessage(ctx, event.Message.ID)
full, err := user.client.GetFullMessage(ctx, event.Message.ID, newProtonAPIScheduler(), proton.NewDefaultAttachmentAllocator())
if err != nil {
// If the message is not found, it means that it has been deleted before we could fetch it.
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Status == http.StatusUnprocessableEntity {
@ -600,7 +689,7 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
var update imap.Update
if err := withAddrKR(user.apiUser, user.apiAddrs[event.Message.AddressID], user.vault.KeyPass(), func(_, addrKR *crypto.KeyRing) error {
res := buildRFC822(user.apiLabels, full, addrKR)
res := buildRFC822(user.apiLabels, full, addrKR, new(bytes.Buffer))
if res.err != nil {
logrus.WithError(err).Error("Failed to build RFC822 message")
@ -637,6 +726,20 @@ func (user *User) handleUpdateDraftEvent(ctx context.Context, event proton.Messa
}, user.apiUserLock, user.apiAddrsLock, user.apiLabelsLock, user.updateChLock)
}
func (user *User) handleUsedSpaceChange(usedSpace int) {
safe.Lock(func() {
if user.apiUser.UsedSpace == usedSpace {
return
}
user.apiUser.UsedSpace = usedSpace
user.eventCh.Enqueue(events.UsedSpaceChanged{
UserID: user.apiUser.ID,
UsedSpace: usedSpace,
})
}, user.apiUserLock)
}
func getMailboxName(label proton.Label) []string {
var name []string

View File

@ -264,8 +264,6 @@ func (conn *imapConnector) DeleteMailbox(ctx context.Context, labelID imap.Mailb
}
// CreateMessage creates a new message on the remote.
//
// nolint:funlen
func (conn *imapConnector) CreateMessage(
ctx context.Context,
mailboxID imap.MailboxID,
@ -292,7 +290,7 @@ func (conn *imapConnector) CreateMessage(
conn.log.WithField("messageID", messageID).Warn("Message already sent")
// Query the server-side message.
full, err := conn.client.GetFullMessage(ctx, messageID)
full, err := conn.client.GetFullMessage(ctx, messageID, newProtonAPIScheduler(), proton.NewDefaultAttachmentAllocator())
if err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to fetch message: %w", err)
}
@ -356,7 +354,7 @@ func (conn *imapConnector) CreateMessage(
}
func (conn *imapConnector) GetMessageLiteral(ctx context.Context, id imap.MessageID) ([]byte, error) {
msg, err := conn.client.GetFullMessage(ctx, string(id))
msg, err := conn.client.GetFullMessage(ctx, string(id), newProtonAPIScheduler(), proton.NewDefaultAttachmentAllocator())
if err != nil {
return nil, err
}
@ -382,7 +380,7 @@ func (conn *imapConnector) GetMessageLiteral(ctx context.Context, id imap.Messag
func (conn *imapConnector) AddMessagesToMailbox(ctx context.Context, messageIDs []imap.MessageID, mailboxID imap.MailboxID) error {
defer conn.goPollAPIEvents(false)
if mailboxID == proton.AllMailLabel {
if isAllMailOrScheduled(mailboxID) {
return connector.ErrOperationNotAllowed
}
@ -393,7 +391,7 @@ func (conn *imapConnector) AddMessagesToMailbox(ctx context.Context, messageIDs
func (conn *imapConnector) RemoveMessagesFromMailbox(ctx context.Context, messageIDs []imap.MessageID, mailboxID imap.MailboxID) error {
defer conn.goPollAPIEvents(false)
if mailboxID == proton.AllMailLabel {
if isAllMailOrScheduled(mailboxID) {
return connector.ErrOperationNotAllowed
}
@ -442,8 +440,8 @@ func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.M
if (labelFromID == proton.InboxLabel && labelToID == proton.SentLabel) ||
(labelFromID == proton.SentLabel && labelToID == proton.InboxLabel) ||
labelFromID == proton.AllMailLabel ||
labelToID == proton.AllMailLabel {
isAllMailOrScheduled(labelFromID) ||
isAllMailOrScheduled(labelToID) {
return false, connector.ErrOperationNotAllowed
}
@ -507,19 +505,20 @@ func (conn *imapConnector) GetUpdates() <-chan imap.Update {
}, conn.updateChLock)
}
// GetUIDValidity returns the default UID validity for this user.
func (conn *imapConnector) GetUIDValidity() imap.UID {
return conn.vault.GetUIDValidity(conn.addrID)
}
// GetMailboxVisibility returns the visibility of a mailbox over IMAP.
func (conn *imapConnector) GetMailboxVisibility(_ context.Context, mailboxID imap.MailboxID) imap.MailboxVisibility {
switch mailboxID {
case proton.AllMailLabel:
if atomic.LoadUint32(&conn.showAllMail) != 0 {
return imap.Visible
}
return imap.Hidden
// SetUIDValidity sets the default UID validity for this user.
func (conn *imapConnector) SetUIDValidity(validity imap.UID) error {
return conn.vault.SetUIDValidity(conn.addrID, validity)
}
// IsMailboxVisible returns whether this mailbox should be visible over IMAP.
func (conn *imapConnector) IsMailboxVisible(_ context.Context, mailboxID imap.MailboxID) bool {
return atomic.LoadUint32(&conn.showAllMail) != 0 || mailboxID != proton.AllMailLabel
case proton.AllScheduledLabel:
return imap.HiddenIfEmpty
default:
return imap.Visible
}
}
// Close the connector will no longer be used and all resources should be closed/released.
@ -550,7 +549,7 @@ func (conn *imapConnector) importMessage(
messageID = msg.ID
} else {
res, err := stream.Collect(ctx, conn.client.ImportMessages(ctx, addrKR, 1, 1, []proton.ImportReq{{
str, err := conn.client.ImportMessages(ctx, addrKR, 1, 1, []proton.ImportReq{{
Metadata: proton.ImportMetadata{
AddressID: conn.addrID,
LabelIDs: labelIDs,
@ -558,7 +557,12 @@ func (conn *imapConnector) importMessage(
Flags: flags,
},
Message: literal,
}}...))
}}...)
if err != nil {
return fmt.Errorf("failed to prepare message for import: %w", err)
}
res, err := stream.Collect(ctx, str)
if err != nil {
return fmt.Errorf("failed to import message: %w", err)
}
@ -568,7 +572,7 @@ func (conn *imapConnector) importMessage(
var err error
if full, err = conn.client.GetFullMessage(ctx, messageID); err != nil {
if full, err = conn.client.GetFullMessage(ctx, messageID, newProtonAPIScheduler(), proton.NewDefaultAttachmentAllocator()); err != nil {
return fmt.Errorf("failed to fetch message: %w", err)
}
@ -615,7 +619,7 @@ func toIMAPMessage(message proton.MessageMetadata) imap.Message {
}
}
func (conn *imapConnector) createDraft(ctx context.Context, literal []byte, addrKR *crypto.KeyRing, sender proton.Address) (proton.Message, error) { //nolint:funlen
func (conn *imapConnector) createDraft(ctx context.Context, literal []byte, addrKR *crypto.KeyRing, sender proton.Address) (proton.Message, error) {
// Create a new message parser from the reader.
parser, err := parser.New(bytes.NewReader(literal))
if err != nil {
@ -687,3 +691,7 @@ func toIMAPMailbox(label proton.Label, flags, permFlags, attrs imap.FlagSet) ima
Attributes: attrs,
}
}
func isAllMailOrScheduled(mailboxID imap.MailboxID) bool {
return (mailboxID == proton.AllMailLabel) || (mailboxID == proton.AllScheduledLabel)
}

View File

@ -218,8 +218,6 @@ func (h *sendRecorder) getWaitCh(hash string) (<-chan struct{}, bool) {
// - the Content-Type header of each (leaf) part,
// - the Content-Disposition header of each (leaf) part,
// - the (decoded) body of each part.
//
// nolint:funlen
func getMessageHash(b []byte) (string, error) {
section := rfc822.Parse(b)

View File

@ -47,8 +47,6 @@ import (
)
// sendMail sends an email from the given address to the given recipients.
//
// nolint:funlen
func (user *User) sendMail(authID string, from string, to []string, r io.Reader) error {
return safe.RLockRet(func() error {
ctx, cancel := context.WithCancel(context.Background())
@ -165,7 +163,7 @@ func (user *User) sendMail(authID string, from string, to []string, r io.Reader)
}
// sendWithKey sends the message with the given address key.
func sendWithKey( //nolint:funlen
func sendWithKey(
ctx context.Context,
client *proton.Client,
sentry reporter.Reporter,
@ -247,7 +245,7 @@ func sendWithKey( //nolint:funlen
return res, nil
}
func getParentID( //nolint:funlen
func getParentID(
ctx context.Context,
client *proton.Client,
authAddrID string,
@ -375,7 +373,6 @@ func createDraft(
})
}
// nolint:funlen
func createAttachments(
ctx context.Context,
client *proton.Client,
@ -468,12 +465,12 @@ func getRecipients(
prefs, err := parallel.MapContext(ctx, runtime.NumCPU(), addresses, func(ctx context.Context, recipient string) (proton.SendPreferences, error) {
pubKeys, recType, err := client.GetPublicKeys(ctx, recipient)
if err != nil {
return proton.SendPreferences{}, fmt.Errorf("failed to get public keys: %w (%v)", err, recipient)
return proton.SendPreferences{}, fmt.Errorf("failed to get public key for %v: %w", recipient, err)
}
contactSettings, err := getContactSettings(ctx, client, userKR, recipient)
if err != nil {
return proton.SendPreferences{}, fmt.Errorf("failed to get contact settings: %w", err)
return proton.SendPreferences{}, fmt.Errorf("failed to get contact settings for %v: %w", recipient, err)
}
return buildSendPrefs(contactSettings, settings, pubKeys, draft.MIMEType, recType == proton.RecipientTypeInternal)

View File

@ -18,6 +18,7 @@
package user
import (
"bytes"
"context"
"fmt"
"runtime"
@ -25,6 +26,7 @@ import (
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/logging"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
@ -34,18 +36,38 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/bradenaw/juniper/parallel"
"github.com/bradenaw/juniper/xslices"
"github.com/google/uuid"
"github.com/pbnjay/memory"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
const (
maxUpdateSize = 1 << 27 // 128 MiB
maxBatchSize = 1 << 8 // 256
)
// syncSystemLabels ensures that system labels are all known to gluon.
func (user *User) syncSystemLabels(ctx context.Context) error {
return safe.RLockRet(func() error {
var updates []imap.Update
// doSync begins syncing the users data.
for _, label := range xslices.Filter(maps.Values(user.apiLabels), func(label proton.Label) bool { return label.Type == proton.LabelTypeSystem }) {
if !wantLabel(label) {
continue
}
for _, updateCh := range xslices.Unique(maps.Values(user.updateCh)) {
update := newSystemMailboxCreatedUpdate(imap.MailboxID(label.ID), label.Name)
updateCh.Enqueue(update)
updates = append(updates, update)
}
}
if err := waitOnIMAPUpdates(ctx, updates); err != nil {
return fmt.Errorf("could not sync system labels: %w", err)
}
return nil
}, user.apiUserLock, user.apiAddrsLock, user.apiLabelsLock, user.updateChLock)
}
// doSync begins syncing the user's data.
// It first ensures the latest event ID is known; if not, it fetches it.
// It sends a SyncStarted event and then either SyncFinished or SyncFailed
// depending on whether the sync was successful.
@ -89,7 +111,6 @@ func (user *User) doSync(ctx context.Context) error {
return nil
}
// nolint:funlen
func (user *User) sync(ctx context.Context) error {
return safe.RLockRet(func() error {
return withAddrKRs(user.apiUser, user.apiAddrs, user.vault.KeyPass(), func(_ *crypto.KeyRing, addrKRs map[string]*crypto.KeyRing) error {
@ -143,7 +164,7 @@ func (user *User) sync(ctx context.Context) error {
addrKRs,
user.updateCh,
user.eventCh,
user.syncWorkers,
user.maxSyncMemory,
); err != nil {
return fmt.Errorf("failed to sync messages: %w", err)
}
@ -166,7 +187,7 @@ func (user *User) sync(ctx context.Context) error {
func syncLabels(ctx context.Context, apiLabels map[string]proton.Label, updateCh ...*queue.QueuedChannel[imap.Update]) error {
var updates []imap.Update
// Create placeholder Folders/Labels mailboxes with a random ID and with the \Noselect attribute.
// Create placeholder Folders/Labels mailboxes with the \Noselect attribute.
for _, prefix := range []string{folderPrefix, labelPrefix} {
for _, updateCh := range updateCh {
update := newPlaceHolderMailboxCreatedUpdate(prefix)
@ -212,7 +233,15 @@ func syncLabels(ctx context.Context, apiLabels map[string]proton.Label, updateCh
return nil
}
// nolint:funlen
const Kilobyte = uint64(1024)
const Megabyte = 1024 * Kilobyte
const Gigabyte = 1024 * Megabyte
func toMB(v uint64) float64 {
return float64(v) / float64(Megabyte)
}
// nolint:gocyclo
func syncMessages(
ctx context.Context,
userID string,
@ -224,7 +253,7 @@ func syncMessages(
addrKRs map[string]*crypto.KeyRing,
updateCh map[string]*queue.QueuedChannel[imap.Update],
eventCh *queue.QueuedChannel[events.Event],
syncWorkers int,
maxSyncMemory uint64,
) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@ -235,78 +264,296 @@ func syncMessages(
logrus.WithFields(logrus.Fields{
"messages": len(messageIDs),
"workers": syncWorkers,
"numCPU": runtime.NumCPU(),
}).Info("Starting message sync")
// Create the flushers, one per update channel.
flushers := make(map[string]*flusher, len(updateCh))
for addrID, updateCh := range updateCh {
flushers[addrID] = newFlusher(updateCh, maxUpdateSize)
}
// Create a reporter to report sync progress updates.
syncReporter := newSyncReporter(userID, eventCh, len(messageIDs), time.Second)
defer syncReporter.done()
type flushUpdate struct {
messageID string
pushedUpdates []imap.Update
batchLen int
// Expected mem usage for this whole process should be the sum of MaxMessageBuildingMem and MaxDownloadRequestMem
// times x due to pipeline and all additional memory used by network requests and compression+io.
// There's no point in using more than 128MB of download data per stage, after that we reach a point of diminishing
// returns as we can't keep the pipeline fed fast enough.
const MaxDownloadRequestMem = 128 * Megabyte
// Any lower than this and we may fail to download messages.
const MinDownloadRequestMem = 40 * Megabyte
// This value can be increased to your hearts content. The more system memory the user has, the more messages
// we can build in parallel.
const MaxMessageBuildingMem = 128 * Megabyte
const MinMessageBuildingMem = 64 * Megabyte
// Maximum recommend value for parallel downloads by the API team.
const maxParallelDownloads = 20
totalMemory := memory.TotalMemory()
if maxSyncMemory >= totalMemory/2 {
logrus.Warnf("Requested max sync memory of %v MB is greater than half of system memory (%v MB), forcing to half of system memory",
maxSyncMemory, toMB(totalMemory/2))
maxSyncMemory = totalMemory / 2
}
if maxSyncMemory < 800*Megabyte {
logrus.Warnf("Requested max sync memory of %v MB, but minimum recommended is 800 MB, forcing max syncMemory to 800MB", toMB(maxSyncMemory))
maxSyncMemory = 800 * Megabyte
}
logrus.Debugf("Total System Memory: %v", toMB(totalMemory))
syncMaxDownloadRequestMem := MaxDownloadRequestMem
syncMaxMessageBuildingMem := MaxMessageBuildingMem
// If less than 2GB available try and limit max memory to 512 MB
switch {
case maxSyncMemory < 2*Gigabyte:
if maxSyncMemory < 800*Megabyte {
logrus.Warnf("System has less than 800MB of memory, you may experience issues sycing large mailboxes")
}
syncMaxDownloadRequestMem = MinDownloadRequestMem
syncMaxMessageBuildingMem = MinMessageBuildingMem
case maxSyncMemory == 2*Gigabyte:
// Increasing the max download capacity has very little effect on sync speed. We could increase the download
// memory but the user would see less sync notifications. A smaller value here leads to more frequent
// updates. Additionally, most of ot sync time is spent in the message building.
syncMaxDownloadRequestMem = MaxDownloadRequestMem
// Currently limited so that if a user has multiple accounts active it also doesn't cause excessive memory usage.
syncMaxMessageBuildingMem = MaxMessageBuildingMem
default:
// Divide by 8 as download stage and build stage will use aprox. 4x the specified memory.
remainingMemory := (maxSyncMemory - 2*Gigabyte) / 8
syncMaxDownloadRequestMem = MaxDownloadRequestMem + remainingMemory
syncMaxMessageBuildingMem = MaxMessageBuildingMem + remainingMemory
}
logrus.Debugf("Max memory usage for sync Download=%vMB Building=%vMB Predicted Max Total=%vMB",
toMB(syncMaxDownloadRequestMem),
toMB(syncMaxMessageBuildingMem),
toMB((syncMaxMessageBuildingMem*4)+(syncMaxDownloadRequestMem*4)),
)
type flushUpdate struct {
messageID string
err error
batchLen int
}
type downloadRequest struct {
ids []string
expectedSize uint64
err error
}
type downloadedMessageBatch struct {
batch []proton.FullMessage
}
type builtMessageBatch struct {
batch []*buildRes
}
downloadCh := make(chan downloadRequest)
buildCh := make(chan downloadedMessageBatch)
// The higher this value, the longer we can continue our download iteration before being blocked on channel writes
// to the update flushing goroutine.
flushCh := make(chan []*buildRes, 2)
flushCh := make(chan builtMessageBatch)
// Allow up to 4 batched wait requests.
flushUpdateCh := make(chan flushUpdate, 4)
flushUpdateCh := make(chan flushUpdate)
errorCh := make(chan error, syncWorkers)
errorCh := make(chan error, maxParallelDownloads*4)
// Go routine in charge of downloading message metadata
logging.GoAnnotated(ctx, func(ctx context.Context) {
defer close(downloadCh)
const MetadataDataPageSize = 150
var downloadReq downloadRequest
downloadReq.ids = make([]string, 0, MetadataDataPageSize)
metadataChunks := xslices.Chunk(messageIDs, MetadataDataPageSize)
for i, metadataChunk := range metadataChunks {
logrus.Debugf("Metadata Request (%v of %v), previous: %v", i, len(metadataChunks), len(downloadReq.ids))
metadata, err := client.GetMessageMetadataPage(ctx, 0, len(metadataChunk), proton.MessageFilter{ID: metadataChunk})
if err != nil {
downloadReq.err = err
select {
case downloadCh <- downloadReq:
case <-ctx.Done():
return
}
return
}
if ctx.Err() != nil {
return
}
// Build look up table so that messages are processed in the same order.
metadataMap := make(map[string]int, len(metadata))
for i, v := range metadata {
metadataMap[v.ID] = i
}
for i, id := range metadataChunk {
m := &metadata[metadataMap[id]]
nextSize := downloadReq.expectedSize + uint64(m.Size)
if nextSize >= syncMaxDownloadRequestMem || len(downloadReq.ids) >= 256 {
logrus.Debugf("Download Request Sent at %v of %v", i, len(metadata))
select {
case downloadCh <- downloadReq:
case <-ctx.Done():
return
}
downloadReq.expectedSize = 0
downloadReq.ids = make([]string, 0, MetadataDataPageSize)
nextSize = uint64(m.Size)
}
downloadReq.ids = append(downloadReq.ids, id)
downloadReq.expectedSize = nextSize
}
}
if len(downloadReq.ids) != 0 {
logrus.Debugf("Sending remaining download request")
select {
case downloadCh <- downloadReq:
case <-ctx.Done():
return
}
}
}, logging.Labels{"sync-stage": "meta-data"})
// Goroutine in charge of downloading and building messages in maxBatchSize batches.
go func() {
defer close(flushCh)
logging.GoAnnotated(ctx, func(ctx context.Context) {
defer close(buildCh)
defer close(errorCh)
defer func() {
logrus.Debugf("sync downloader exit")
}()
attachmentDownloader := newAttachmentDownloader(ctx, client, maxParallelDownloads)
defer attachmentDownloader.close()
for request := range downloadCh {
logrus.Debugf("Download request: %v MB:%v", len(request.ids), toMB(request.expectedSize))
if request.err != nil {
errorCh <- request.err
return
}
for _, batch := range xslices.Chunk(messageIDs, maxBatchSize) {
if ctx.Err() != nil {
errorCh <- ctx.Err()
return
}
result, err := parallel.MapContext(ctx, syncWorkers, batch, func(ctx context.Context, id string) (*buildRes, error) {
msg, err := client.GetFullMessage(ctx, id)
result, err := parallel.MapContext(ctx, maxParallelDownloads, request.ids, func(ctx context.Context, id string) (proton.FullMessage, error) {
var result proton.FullMessage
msg, err := client.GetMessage(ctx, id)
if err != nil {
return nil, err
return proton.FullMessage{}, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
attachments, err := attachmentDownloader.getAttachments(ctx, msg.Attachments)
if err != nil {
return proton.FullMessage{}, err
}
return buildRFC822(apiLabels, msg, addrKRs[msg.AddressID]), nil
result.Message = msg
result.AttData = attachments
return result, nil
})
if err != nil {
errorCh <- err
return
}
select {
case buildCh <- downloadedMessageBatch{
batch: result,
}:
case <-ctx.Done():
return
}
}
}, logging.Labels{"sync-stage": "download"})
// Goroutine which builds messages after they have been downloaded
logging.GoAnnotated(ctx, func(ctx context.Context) {
defer close(flushCh)
defer func() {
logrus.Debugf("sync builder exit")
}()
maxMessagesInParallel := runtime.NumCPU()
for buildBatch := range buildCh {
if ctx.Err() != nil {
errorCh <- ctx.Err()
return
}
flushCh <- result
chunks := chunkSyncBuilderBatch(buildBatch.batch, syncMaxMessageBuildingMem)
for index, chunk := range chunks {
logrus.Debugf("Build request: %v of %v count=%v", index, len(chunks), len(chunk))
result, err := parallel.MapContext(ctx, maxMessagesInParallel, chunk, func(ctx context.Context, msg proton.FullMessage) (*buildRes, error) {
return buildRFC822(apiLabels, msg, addrKRs[msg.AddressID], new(bytes.Buffer)), nil
})
if err != nil {
return
}
select {
case flushCh <- builtMessageBatch{result}:
case <-ctx.Done():
return
}
}
}
}()
}, logging.Labels{"sync-stage": "builder"})
// Goroutine which converts the messages into updates and builds a waitable structure for progress tracking.
go func() {
logging.GoAnnotated(ctx, func(ctx context.Context) {
defer close(flushUpdateCh)
for batch := range flushCh {
for _, res := range batch {
defer func() {
logrus.Debugf("sync flush exit")
}()
type updateTargetInfo struct {
queueIndex int
ch *queue.QueuedChannel[imap.Update]
}
pendingUpdates := make([][]*imap.MessageCreated, len(updateCh))
addressToIndex := make(map[string]updateTargetInfo)
{
i := 0
for addrID, updateCh := range updateCh {
addressToIndex[addrID] = updateTargetInfo{
ch: updateCh,
queueIndex: i,
}
i++
}
}
for downloadBatch := range flushCh {
logrus.Debugf("Flush batch: %v", len(downloadBatch.batch))
for _, res := range downloadBatch.batch {
if res.err != nil {
if err := vault.AddFailedMessageID(res.messageID); err != nil {
logrus.WithError(err).Error("Failed to add failed message ID")
@ -327,31 +574,38 @@ func syncMessages(
}
}
flushers[res.addressID].push(res.update)
targetInfo := addressToIndex[res.addressID]
pendingUpdates[targetInfo.queueIndex] = append(pendingUpdates[targetInfo.queueIndex], res.update)
}
var pushedUpdates []imap.Update
for _, flusher := range flushers {
flusher.flush()
pushedUpdates = append(pushedUpdates, flusher.collectPushedUpdates()...)
for _, info := range addressToIndex {
up := imap.NewMessagesCreated(true, pendingUpdates[info.queueIndex]...)
info.ch.Enqueue(up)
err, ok := up.WaitContext(ctx)
if ok && err != nil {
flushUpdateCh <- flushUpdate{
err: fmt.Errorf("failed to apply sync update to gluon %v: %w", up.String(), err),
}
return
}
pendingUpdates[info.queueIndex] = pendingUpdates[info.queueIndex][:0]
}
flushUpdateCh <- flushUpdate{
messageID: batch[0].messageID,
pushedUpdates: pushedUpdates,
batchLen: len(batch),
select {
case flushUpdateCh <- flushUpdate{
messageID: downloadBatch.batch[0].messageID,
err: nil,
batchLen: len(downloadBatch.batch),
}:
case <-ctx.Done():
return
}
}
}()
}, logging.Labels{"sync-stage": "flush"})
for flushUpdate := range flushUpdateCh {
for _, up := range flushUpdate.pushedUpdates {
err, ok := up.WaitContext(ctx)
if ok && err != nil {
return fmt.Errorf("failed to apply sync update to gluon %v: %w", up.String(), err)
}
}
if err := vault.SetLastMessageID(flushUpdate.messageID); err != nil {
return fmt.Errorf("failed to set last synced message ID: %w", err)
}
@ -394,6 +648,9 @@ func newSystemMailboxCreatedUpdate(labelID imap.MailboxID, labelName string) *im
case proton.StarredLabel:
attrs = attrs.Add(imap.AttrFlagged)
case proton.AllScheduledLabel:
labelName = "Scheduled" // API actual name is "All Scheduled"
}
return imap.NewMailboxCreated(imap.Mailbox{
@ -407,7 +664,7 @@ func newSystemMailboxCreatedUpdate(labelID imap.MailboxID, labelName string) *im
func newPlaceHolderMailboxCreatedUpdate(labelName string) *imap.MailboxCreated {
return imap.NewMailboxCreated(imap.Mailbox{
ID: imap.MailboxID(uuid.NewString()),
ID: imap.MailboxID(labelName),
Name: []string{labelName},
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
@ -456,6 +713,9 @@ func wantLabel(label proton.Label) bool {
case proton.StarredLabel:
return true
case proton.AllScheduledLabel:
return true
default:
return false
}
@ -471,3 +731,126 @@ func wantLabels(apiLabels map[string]proton.Label, labelIDs []string) []string {
return wantLabel(apiLabel)
})
}
type attachmentResult struct {
attachment []byte
err error
}
type attachmentJob struct {
id string
size int64
result chan attachmentResult
}
type attachmentDownloader struct {
workerCh chan attachmentJob
cancel context.CancelFunc
}
func attachmentWorker(ctx context.Context, client *proton.Client, work <-chan attachmentJob) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-work:
if !ok {
return
}
var b bytes.Buffer
b.Grow(int(job.size))
err := client.GetAttachmentInto(ctx, job.id, &b)
select {
case <-ctx.Done():
close(job.result)
return
case job.result <- attachmentResult{attachment: b.Bytes(), err: err}:
close(job.result)
}
}
}
}
func newAttachmentDownloader(ctx context.Context, client *proton.Client, workerCount int) *attachmentDownloader {
workerCh := make(chan attachmentJob, (workerCount+2)*workerCount)
ctx, cancel := context.WithCancel(ctx)
for i := 0; i < workerCount; i++ {
workerCh = make(chan attachmentJob)
logging.GoAnnotated(ctx, func(ctx context.Context) { attachmentWorker(ctx, client, workerCh) }, logging.Labels{
"sync": fmt.Sprintf("att-downloader %v", i),
})
}
return &attachmentDownloader{
workerCh: workerCh,
cancel: cancel,
}
}
func (a *attachmentDownloader) getAttachments(ctx context.Context, attachments []proton.Attachment) ([][]byte, error) {
resultChs := make([]chan attachmentResult, len(attachments))
for i, id := range attachments {
resultChs[i] = make(chan attachmentResult, 1)
select {
case a.workerCh <- attachmentJob{id: id.ID, result: resultChs[i], size: id.Size}:
case <-ctx.Done():
return nil, ctx.Err()
}
}
result := make([][]byte, len(attachments))
var err error
for i := 0; i < len(attachments); i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-resultChs[i]:
if r.err != nil {
err = fmt.Errorf("failed to get attachment %v: %w", attachments[i], r.err)
}
result[i] = r.attachment
}
}
return result, err
}
func (a *attachmentDownloader) close() {
a.cancel()
}
func chunkSyncBuilderBatch(batch []proton.FullMessage, maxMemory uint64) [][]proton.FullMessage {
var expectedMemUsage uint64
var chunks [][]proton.FullMessage
var lastIndex int
var index int
for _, v := range batch {
var dataSize uint64
for _, a := range v.Attachments {
dataSize += uint64(a.Size)
}
// 2x increase for attachment due to extra memory needed for decrypting and writing
// in memory buffer.
dataSize *= 2
dataSize += uint64(len(v.Body))
nextMemSize := expectedMemUsage + dataSize
if nextMemSize >= maxMemory {
chunks = append(chunks, batch[lastIndex:index])
lastIndex = index
expectedMemUsage = dataSize
} else {
expectedMemUsage = nextMemSize
}
index++
}
if lastIndex < len(batch) {
chunks = append(chunks, batch[lastIndex:])
}
return chunks
}

View File

@ -48,16 +48,18 @@ func defaultJobOpts() message.JobOptions {
}
}
func buildRFC822(apiLabels map[string]proton.Label, full proton.FullMessage, addrKR *crypto.KeyRing) *buildRes {
func buildRFC822(apiLabels map[string]proton.Label, full proton.FullMessage, addrKR *crypto.KeyRing, buffer *bytes.Buffer) *buildRes {
var (
update *imap.MessageCreated
err error
)
if literal, buildErr := message.BuildRFC822(addrKR, full.Message, full.AttData, defaultJobOpts()); buildErr != nil {
buffer.Grow(full.Size)
if buildErr := message.BuildRFC822Into(addrKR, full.Message, full.AttData, defaultJobOpts(), buffer); buildErr != nil {
update = newMessageCreatedFailedUpdate(apiLabels, full.MessageMetadata, buildErr)
err = buildErr
} else if created, parseErr := newMessageCreatedUpdate(apiLabels, full.MessageMetadata, literal); parseErr != nil {
} else if created, parseErr := newMessageCreatedUpdate(apiLabels, full.MessageMetadata, buffer.Bytes()); parseErr != nil {
update = newMessageCreatedFailedUpdate(apiLabels, full.MessageMetadata, parseErr)
err = parseErr
} else {

View File

@ -24,6 +24,8 @@ import (
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/bradenaw/juniper/xslices"
"github.com/stretchr/testify/require"
)
@ -47,3 +49,32 @@ func TestNewFailedMessageLiteral(t *testing.T) {
require.Equal(t, `("text" "plain" () NIL NIL "base64" 114 2)`, parsed.Body)
require.Equal(t, `("text" "plain" () NIL NIL "base64" 114 2 NIL NIL NIL NIL)`, parsed.Structure)
}
func TestSyncChunkSyncBuilderBatch(t *testing.T) {
// GODT-2424 - Some messages were not fully built due to a bug in the chunking if the total memory used by the
// message would be higher than the maximum we allowed.
const totalMessageCount = 100
msg := proton.FullMessage{
Message: proton.Message{
Attachments: []proton.Attachment{
{
Size: int64(8 * Megabyte),
},
},
},
AttData: nil,
}
messages := xslices.Repeat(msg, totalMessageCount)
chunks := chunkSyncBuilderBatch(messages, 16*Megabyte)
var totalMessagesInChunks int
for _, v := range chunks {
totalMessagesInChunks += len(v)
}
require.Equal(t, totalMessagesInChunks, totalMessageCount)
}

Some files were not shown because too many files have changed in this diff Show More