Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 77 additions & 9 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ type Backend struct {
etherScanRateLimiter *rate.Limiter
ratesUpdater *rates.RateUpdater
banners *banners.Banners
started bool

// For unit tests, called when `backend.checkAccountUsed()` is called.
tstCheckAccountUsed func(accounts.Interface) bool
Expand Down Expand Up @@ -328,15 +329,7 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe
backend.httpClient = hclient
backend.ethupdater = eth.NewUpdater(accountUpdate, backend.httpClient, backend.etherScanRateLimiter, backend.updateETHAccounts)

ratesCache := filepath.Join(arguments.CacheDirectoryPath(), "exchangerates")
if err := os.MkdirAll(ratesCache, 0700); err != nil {
log.Errorf("RateUpdater DB cache dir: %v", err)
}
backend.ratesUpdater = rates.NewRateUpdater(hclient, ratesCache)
backend.ratesUpdater.Observe(func(event observable.Event) {
backend.Notify(event)
backend.notifyCoinFiatPrices()
})
backend.ratesUpdater = backend.newRatesUpdater()

backend.banners = banners.NewBanners(backend.DevServers())
backend.banners.Observe(backend.Notify)
Expand All @@ -347,6 +340,37 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe
return backend, nil
}

func (backend *Backend) newRatesUpdater() *rates.RateUpdater {
ratesCache := filepath.Join(backend.arguments.CacheDirectoryPath(), "exchangerates")
if err := os.MkdirAll(ratesCache, 0700); err != nil {
backend.log.Errorf("RateUpdater DB cache dir: %v", err)
}
updater := rates.NewRateUpdater(backend.httpClient, ratesCache)
updater.Observe(func(event observable.Event) {
backend.Notify(event)
backend.notifyCoinFiatPrices()
})
return updater
}

func (backend *Backend) closeCoins() error {
unlock := backend.coinsLock.Lock()
coins := backend.coins
backend.coins = map[coinpkg.Code]coinpkg.Coin{}
unlock()

errors := []string{}
for code, coin := range coins {
if err := coin.Close(); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", code, err))
}
}
if len(errors) > 0 {
return errp.New(strings.Join(errors, "; "))
}
return nil
}

// configureHistoryExchangeRates changes backend.ratesUpdater settings.
// It requires both backend.config to be up-to-date and all accounts initialized.
//
Expand Down Expand Up @@ -697,6 +721,7 @@ func (backend *Backend) Start() <-chan interface{} {

backend.ratesUpdater.StartCurrentRates()
backend.configureHistoryExchangeRates()
backend.started = true

backend.environment.OnAuthSettingChanged(backend.config.AppConfig().Backend.Authentication)

Expand Down Expand Up @@ -1002,8 +1027,51 @@ func (backend *Backend) Environment() Environment {
return backend.environment
}

// ClearCache clears the backend cache directory and reinitializes cache-backed state.
// User data such as configs, account names, wallets and notes is preserved.
func (backend *Backend) ClearCache() error {
defer backend.accountsAndKeystoreLock.Lock()()

backend.log.Info("Clearing backend cache")

errors := []string{}

if backend.ratesUpdater != nil {
backend.ratesUpdater.Stop()
}

backend.uninitAccounts(true)
if err := backend.closeCoins(); err != nil {
backend.log.WithError(err).Error("could not close coins before clearing cache")
errors = append(errors, err.Error())
}

cacheDir := backend.arguments.CacheDirectoryPath()
if err := os.RemoveAll(cacheDir); err != nil {
backend.log.WithError(err).Error("could not remove cache directory")
errors = append(errors, err.Error())
}
if err := os.MkdirAll(cacheDir, 0700); err != nil {
backend.log.WithError(err).Error("could not recreate cache directory")
errors = append(errors, err.Error())
}

backend.ratesUpdater = backend.newRatesUpdater()
backend.initAccounts(true)
if backend.started {
backend.ratesUpdater.StartCurrentRates()
}
backend.notifyCoinFiatPrices()

if len(errors) > 0 {
return errp.New(strings.Join(errors, "; "))
}
return nil
}

// Close shuts down the backend. After this, no other method should be called.
func (backend *Backend) Close() error {
backend.started = false
backend.ratesUpdater.Stop()
// Call this without `accountsAndKeystoreLock` as it eventually calls `DeregisterKeystore()`,
// which acquires the same lock.
Expand Down
23 changes: 23 additions & 0 deletions backend/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"math/big"
"net/http"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -321,6 +322,28 @@ func newBackend(t *testing.T, testing, regtest bool) *Backend {
return b
}

func TestClearCachePreservesUserData(t *testing.T) {
b := newBackend(t, false, false)
defer b.Close()

cacheFile := filepath.Join(b.arguments.CacheDirectoryPath(), "dummy-cache-file")
require.NoError(t, os.WriteFile(cacheFile, []byte("cache"), 0600))

noteFile := filepath.Join(b.arguments.NotesDirectoryPath(), "dummy-note-file")
require.NoError(t, os.WriteFile(noteFile, []byte("note"), 0600))

require.FileExists(t, b.arguments.AppConfigFilename())
require.FileExists(t, b.arguments.AccountsConfigFilename())

require.NoError(t, b.ClearCache())

require.NoFileExists(t, cacheFile)
require.DirExists(t, filepath.Join(b.arguments.CacheDirectoryPath(), "exchangerates"))
require.FileExists(t, noteFile)
require.FileExists(t, b.arguments.AppConfigFilename())
require.FileExists(t, b.arguments.AccountsConfigFilename())
}

func TestRegisterKeystore(t *testing.T) {
// From mnemonic: wisdom minute home employ west tail liquid mad deal catalog narrow mistake
rootKey1 := test.TstMustXKey("xprv9s21ZrQH143K3gie3VFLgx8JcmqZNsBcBc6vAdJrsf4bPRhx69U8qZe3EYAyvRWyQdEfz7ZpyYtL8jW2d2Lfkfh6g2zivq8JdZPQqxoxLwB")
Expand Down
13 changes: 13 additions & 0 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type Backend interface {
CheckForUpdateIgnoringErrors() *backend.UpdateFile
Banners() *banners.Banners
Environment() backend.Environment
ClearCache() error
ExportLogs() error
ExportNotes() error
ImportNotes(jsonLines []byte) (*backend.ImportNotesResult, error)
Expand Down Expand Up @@ -263,6 +264,7 @@ func NewHandlers(
getAPIRouterNoError(apiRouter)("/cancel-connect-keystore", handlers.postCancelConnectKeystore).Methods("POST")
getAPIRouterNoError(apiRouter)("/set-watchonly", handlers.postSetWatchonly).Methods("POST")
getAPIRouterNoError(apiRouter)("/on-auth-setting-changed", handlers.postOnAuthSettingChanged).Methods("POST")
getAPIRouterNoError(apiRouter)("/clear-cache", handlers.postClearCache).Methods("POST")
getAPIRouterNoError(apiRouter)("/export-log", handlers.postExportLog).Methods("POST")
getAPIRouterNoError(apiRouter)("/accounts/eth-account-code", handlers.lookupEthAccountCode).Methods("POST")
getAPIRouterNoError(apiRouter)("/notes/export", handlers.postExportNotes).Methods("POST")
Expand Down Expand Up @@ -1654,6 +1656,17 @@ func (handlers *Handlers) postOnAuthSettingChanged(r *http.Request) interface{}
return nil
}

func (handlers *Handlers) postClearCache(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
if err := handlers.backend.ClearCache(); err != nil {
return result{Success: false, ErrorMessage: err.Error()}
}
return result{Success: true}
}

func (handlers *Handlers) postExportLog(r *http.Request) interface{} {
type result struct {
Success bool `json:"success"`
Expand Down
4 changes: 4 additions & 0 deletions frontends/web/src/api/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export const exportLogs = (): Promise<TSuccess> => {
return apiPost('export-log');
};

export const clearCache = (): Promise<TSuccess> => {
return apiPost('clear-cache');
};

export const exportNotes = (): Promise<(FailResponse & { aborted: boolean }) | SuccessResponse> => {
return apiPost('notes/export');
};
Expand Down
9 changes: 9 additions & 0 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -1940,6 +1940,15 @@
"title-tltc": "Litecoin Testnet Electrum servers"
},
"expert": {
"clearCache": {
"description": "Clear the cache of the BitBoxApp. This can help fix issues in the app.",
"dialog": {
"description": "Clear the cache of the BitBoxApp. If you are experiencing issues with the BitBoxApp, this can help resolve them.",
"note": "Your account names, notes and wallets are not affected.",
"primaryCTA": "Clear Cache"
},
"title": "Clear cache"
},
"coinControl": "Enable coin control",
"electrum": {
"description": "You can connect to your own Electrum full node.",
Expand Down
2 changes: 2 additions & 0 deletions frontends/web/src/routes/settings/advanced-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { EnableTorProxySetting } from './components/advanced-settings/enable-tor
import { UnlockSoftwareKeystore } from './components/advanced-settings/unlock-software-keystore';
import { RestartInTestnetSetting } from './components/advanced-settings/restart-in-testnet-setting';
import { ExportLogSetting } from './components/advanced-settings/export-log-setting';
import { ClearCacheSetting } from './components/advanced-settings/clear-cache-setting';
import { CustomGapLimitSettings } from './components/advanced-settings/custom-gap-limit-setting';
import { getConfig } from '@/utils/config';
import { MobileHeader } from './components/mobile-header';
Expand Down Expand Up @@ -91,6 +92,7 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting
<CustomGapLimitSettings backendConfig={backendConfig} onChangeConfig={setConfig} />
<UnlockSoftwareKeystore deviceIDs={deviceIDs}/>
<ConnectFullNodeSetting />
<ClearCacheSetting />
<ExportLogSetting />
</WithSettingsTabs>
</ViewContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, beforeEach, Mock, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ClearCacheSetting } from './clear-cache-setting';

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => ({
'button.back': 'Back',
'genericError': 'An error occurred. If you notice any issues, please restart the application.',
'settings.expert.clearCache.description': 'Clear the cache of the BitBoxApp. This can help fix issues in the app.',
'settings.expert.clearCache.dialog.description': 'Clear the cache of the BitBoxApp. If you are experiencing issues with the BitBoxApp, this can help resolve them.',
'settings.expert.clearCache.dialog.note': 'Your account names, notes and wallets are not affected.',
'settings.expert.clearCache.dialog.primaryCTA': 'Clear Cache',
'settings.expert.clearCache.title': 'Clear cache',
}[key] ?? key),
}),
}));

vi.mock('@/api/backend', () => ({
clearCache: vi.fn(),
}));

vi.mock('@/components/alert/Alert', () => ({
alertUser: vi.fn(),
}));

vi.mock('@/hooks/mediaquery', () => ({
useMediaQuery: vi.fn().mockReturnValue(false),
}));

import { clearCache } from '@/api/backend';

describe('routes/settings/components/advanced-settings/clear-cache-setting', () => {
beforeEach(() => {
vi.clearAllMocks();
(clearCache as Mock).mockResolvedValue({ success: true });
});

it('opens the dialog and clears the cache', async () => {
const user = userEvent.setup();

render(<ClearCacheSetting />);

await user.click(screen.getByRole('button', { name: /Clear cache/i }));

expect(screen.getByRole('heading', { name: 'Clear cache' })).toBeInTheDocument();
expect(screen.getByText('Clear the cache of the BitBoxApp. If you are experiencing issues with the BitBoxApp, this can help resolve them.')).toBeInTheDocument();
expect(screen.getByText('Your account names, notes and wallets are not affected.')).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Clear Cache' }));

await waitFor(() => {
expect(clearCache).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: Apache-2.0

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { clearCache } from '@/api/backend';
import { alertUser } from '@/components/alert/Alert';
import { Dialog, DialogButtons } from '@/components/dialog/dialog';
import { Button } from '@/components/forms';
import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem';

export const ClearCacheSetting = () => {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false);
const [clearing, setClearing] = useState(false);

const closeDialog = () => {
if (clearing) {
return;
}
setDialogOpen(false);
};

const handleClearCache = async () => {
setClearing(true);
try {
const result = await clearCache();
if (!result.success) {
alertUser(result.errorMessage || t('genericError'));
return;
}
setDialogOpen(false);
} catch (err) {
console.error(err);
alertUser(t('genericError'));
} finally {
setClearing(false);
}
};

return (
<>
<SettingsItem
settingName={t('settings.expert.clearCache.title')}
secondaryText={t('settings.expert.clearCache.description')}
onClick={() => setDialogOpen(true)}
/>
<Dialog
open={dialogOpen}
onClose={clearing ? undefined : closeDialog}
title={t('settings.expert.clearCache.title')}
medium>
<p>{t('settings.expert.clearCache.dialog.description')}</p>
<p>{t('settings.expert.clearCache.dialog.note')}</p>
<DialogButtons>
<Button primary disabled={clearing} onClick={handleClearCache}>
{t('settings.expert.clearCache.dialog.primaryCTA')}
</Button>
<Button secondary disabled={clearing} onClick={closeDialog}>
{t('button.back')}
</Button>
</DialogButtons>
</Dialog>
</>
);
};