From 4b26a3e4ef1677a2cd7a7f3232698bcde21ca0d0 Mon Sep 17 00:00:00 2001 From: Jad <42831937+jadzeidan@users.noreply.github.com> Date: Sat, 9 May 2026 13:41:32 +0200 Subject: [PATCH] clear cache function add clear cache button in advanced settings so users don't need to manually navigate folders to delete them. --- backend/backend.go | 86 +++++++++++++++++-- backend/backend_test.go | 23 +++++ backend/handlers/handlers.go | 13 +++ frontends/web/src/api/backend.ts | 4 + frontends/web/src/locales/en/app.json | 9 ++ .../src/routes/settings/advanced-settings.tsx | 2 + .../clear-cache-setting.test.tsx | 59 +++++++++++++ .../advanced-settings/clear-cache-setting.tsx | 65 ++++++++++++++ 8 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.test.tsx create mode 100644 frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.tsx diff --git a/backend/backend.go b/backend/backend.go index ed913c8f8f..4bba358174 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -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 @@ -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) @@ -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. // @@ -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) @@ -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. diff --git a/backend/backend_test.go b/backend/backend_test.go index 983af5227e..dc691acf99 100644 --- a/backend/backend_test.go +++ b/backend/backend_test.go @@ -7,6 +7,7 @@ import ( "math/big" "net/http" "os" + "path/filepath" "testing" "time" @@ -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") diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 8ab9077779..750b26e888 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -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) @@ -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") @@ -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"` diff --git a/frontends/web/src/api/backend.ts b/frontends/web/src/api/backend.ts index 0b35ae4b4e..efd921a87d 100644 --- a/frontends/web/src/api/backend.ts +++ b/frontends/web/src/api/backend.ts @@ -141,6 +141,10 @@ export const exportLogs = (): Promise => { return apiPost('export-log'); }; +export const clearCache = (): Promise => { + return apiPost('clear-cache'); +}; + export const exportNotes = (): Promise<(FailResponse & { aborted: boolean }) | SuccessResponse> => { return apiPost('notes/export'); }; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..1f9316336e 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -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.", diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 2482cf13bd..997a7aa823 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -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'; @@ -91,6 +92,7 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting + diff --git a/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.test.tsx b/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.test.tsx new file mode 100644 index 0000000000..dd6afed8d2 --- /dev/null +++ b/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.test.tsx @@ -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(); + + 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); + }); + }); +}); diff --git a/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.tsx new file mode 100644 index 0000000000..f6a2199c03 --- /dev/null +++ b/frontends/web/src/routes/settings/components/advanced-settings/clear-cache-setting.tsx @@ -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 ( + <> + setDialogOpen(true)} + /> + +

{t('settings.expert.clearCache.dialog.description')}

+

{t('settings.expert.clearCache.dialog.note')}

+ + + + +
+ + ); +};