diff --git a/package-lock.json b/package-lock.json index cc3f4ba..0d2b58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bsv-desktop-electron", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bsv-desktop-electron", - "version": "2.3.0", + "version": "2.3.1", "license": "Apache-2.0", "dependencies": { "@bsv/amountinator": "^2.0.1", diff --git a/package.json b/package.json index 3883ad8..029292f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsv-desktop-electron", - "version": "2.3.0", + "version": "2.3.1", "description": "BSV Desktop Wallet - Electron Edition", "main": "dist-electron/main.js", "type": "module", diff --git a/src/lib/WalletContext.tsx b/src/lib/WalletContext.tsx index 13d8091..40b5a6e 100644 --- a/src/lib/WalletContext.tsx +++ b/src/lib/WalletContext.tsx @@ -21,14 +21,12 @@ import React, { } from 'react' import { useMediaQuery } from '@mui/material' import { DEFAULT_SETTINGS, WalletSettings } from '@bsv/wallet-toolbox-client/out/src/WalletSettingsManager' -import { WalletPermissionsManager, PrivilegedKeyManager } from '@bsv/wallet-toolbox-client' -import { WalletStorageManager, WalletAuthenticationManager } from '@bsv/wallet-toolbox-client' -import { WalletInterface } from '@bsv/sdk' +import { WalletPermissionsManager, PrivilegedKeyManager, WalletStorageManager, WalletAuthenticationManager } from '@bsv/wallet-toolbox-client' +import { WalletInterface, Utils } from '@bsv/sdk' import { PeerPayClient, AdvertisementToken } from '@bsv/message-box-client' -import { toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' -import { DEFAULT_CHAIN, ADMIN_ORIGINATOR, DEFAULT_USE_WAB } from './config' +import { ADMIN_ORIGINATOR } from './config' import { UserContext } from './UserContext' import { useWalletService, getWalletService } from './hooks/useWalletService' import { buildPermissionModuleRegistry } from './permissionModules/registry' @@ -37,7 +35,6 @@ import type { GroupPermissionRequest, CounterpartyPermissionRequest } from './ty import type { WalletProfile } from './types/WalletProfile' import { RequestInterceptorWallet } from './RequestInterceptorWallet' import { updateRecentApp } from './pages/Dashboard/Apps/getApps' -import { Utils } from '@bsv/sdk' // ----- // Permission Configuration Types (preserved for backward compatibility) @@ -98,7 +95,6 @@ interface ManagerState { walletManager?: WalletAuthenticationManager; permissionsManager?: WalletPermissionsManager; settingsManager?: any; - wallet?: WalletInterface; storageManager?: WalletStorageManager; } @@ -118,6 +114,13 @@ export interface WABConfig { export interface WalletContextValue { managers: ManagerState; updateManagers: (newManagers: ManagerState) => void; + /** + * Raw, unwrapped `Wallet` from `@bsv/wallet-toolbox`. Standalone — kept + * outside `managers` so it is never confused with `permissionsManager`. + * Internal/first-party use only (e.g. diagnostic UI, BRC-103 handshake + * plumbing). App-originated requests must go through `managers.permissionsManager`. + */ + wallet?: WalletInterface; settings: WalletSettings; updateSettings: (newSettings: WalletSettings) => Promise; network: 'mainnet' | 'testnet'; @@ -161,6 +164,7 @@ export interface WalletContextValue { addBackupStorageUrl: (url: string) => Promise; removeBackupStorageUrl: (url: string) => Promise; syncBackupStorage: (progressCallback?: (message: string) => void) => Promise; + setPrimaryStorage: (target: string, progressCallback?: (message: string) => void) => Promise; updateMessageBoxUrl: (url: string) => Promise; removeMessageBoxUrl: () => Promise; initializingBackendServices: boolean; @@ -221,6 +225,7 @@ export const WalletContext = createContext({ addBackupStorageUrl: async () => {}, removeBackupStorageUrl: async () => {}, syncBackupStorage: async () => {}, + setPrimaryStorage: async () => {}, updateMessageBoxUrl: async () => {}, removeMessageBoxUrl: async () => {}, initializingBackendServices: false, @@ -314,18 +319,10 @@ export const WalletContextProvider: React.FC = ({ svc.permissionQueue.setPermissionsModuleHelpers(getPermissionModuleById as any, permissionPromptHandlersRef.current) }, [enabledPermissionModules, getPermissionModuleById, svc]) - // ---- Permissions config (load from localStorage once) ---- - useEffect(() => { - try { - const stored = localStorage.getItem('permissionsConfig') - if (stored) { - const merged = { ...DEFAULT_PERMISSIONS_CONFIG, ...JSON.parse(stored) } - svc.permissionQueue.permissionsConfig = merged - } - } catch (e) { - console.error('Failed to load permissions config:', e) - } - }, [svc]) + // Permissions config is loaded from localStorage inside getWalletService() + // before React mounts, so the primed _queueSnapshot already reflects the + // saved value. Loading here in a useEffect creates a race against + // useSyncExternalStore's subscribe phase and the snapshot update is lost. // ---- Dark mode for permission prompts ---- const tokenPromptPaletteMode = useMemo(() => { @@ -344,9 +341,11 @@ export const WalletContextProvider: React.FC = ({ const DEBOUNCE_TIME_MS = 5000 useEffect(() => { - // Use managers.wallet (set by _buildWallet) instead of walletManager.authenticated - // SimpleWalletManager (direct-key) doesn't expose an authenticated property - const walletReady = !!managers?.wallet + // External BRC-100 traffic (port 3321) hits permissionsManager so app-originated + // requests pass through permission prompts. The standalone raw `wallet` (separate + // from `managers`) is reserved for internal wallet-toolbox plumbing that + // intentionally bypasses permissions (e.g. StorageClient BRC-103 handshake). + const walletReady = !!managers?.permissionsManager console.log('[onWalletReady effect] check:', { walletReady, profileId: activeProfile?.id ? `[${activeProfile.id.length} bytes]` : null, @@ -357,7 +356,6 @@ export const WalletContextProvider: React.FC = ({ } console.log('[onWalletReady effect] guard passed — registering wallet ref') - const wallet = managers.wallet! const updateRecentAppWrapper = async (profileId: string, origin: string): Promise => { try { @@ -367,18 +365,18 @@ export const WalletContextProvider: React.FC = ({ if (lastProcessed && (now - lastProcessed) < DEBOUNCE_TIME_MS) return recentOriginsRef.current.set(cacheKey, now) await updateRecentApp(profileId, origin) - window.dispatchEvent(new CustomEvent('recentAppsUpdated', { detail: { profileId, origin } })) + globalThis.dispatchEvent(new CustomEvent('recentAppsUpdated', { detail: { profileId, origin } })) } catch (error) { console.debug('Error tracking recent app:', error) } } - const interceptorWallet = new RequestInterceptorWallet(wallet, Utils.toBase64(activeProfile.id), updateRecentAppWrapper) + const interceptorWallet = new RequestInterceptorWallet(managers.permissionsManager, Utils.toBase64(activeProfile.id), updateRecentAppWrapper) // onWalletReady registers IPC listener once, subsequent calls just swap wallet ref onWalletReady(interceptorWallet) // No cleanup — IPC listener is permanent, wallet ref is swapped not re-registered - }, [managers?.wallet, activeProfile?.id, onWalletReady]) + }, [managers?.permissionsManager, activeProfile?.id, onWalletReady]) // ---- Context value ---- const contextValue = useMemo(() => ({ diff --git a/src/lib/components/WalletConfig.tsx b/src/lib/components/WalletConfig.tsx index 33cb242..8c951ae 100644 --- a/src/lib/components/WalletConfig.tsx +++ b/src/lib/components/WalletConfig.tsx @@ -122,8 +122,20 @@ const WalletConfig: React.FC = ({ autoExpand = false, hideLog useRemoteStorage, useMessageBox, }) - if (valid) setShowWalletConfig(false) - }, [wabUrl, wabInfo, method, network, storageUrl, messageBoxUrl, loginType, useRemoteStorage, useMessageBox, finalizeConfig, setShowWalletConfig]) + if (valid) { + // The toggle/close path calls `resetCurrentConfig()`, which re-applies `backupConfig` + // (the pre-edit snapshot). After a successful Apply we must clear `backupConfig` so + // that a subsequent close — including the parent's auto-close below — doesn't + // immediately revert the freshly-applied configuration. + setBackupConfig(undefined) + if (isControlled) { + // Parent owns visibility; signal it to close the panel automatically. + onToggle?.() + } else { + setShowWalletConfig(false) + } + } + }, [wabUrl, wabInfo, method, network, storageUrl, messageBoxUrl, loginType, useRemoteStorage, useMessageBox, finalizeConfig, setShowWalletConfig, isControlled, onToggle]) // Force the manager to use the "presentation-key-and-password" flow (only for WAB/CWIStyle managers): useEffect(() => { diff --git a/src/lib/hooks/useWalletService.ts b/src/lib/hooks/useWalletService.ts index 00132b1..8e6fdee 100644 --- a/src/lib/hooks/useWalletService.ts +++ b/src/lib/hooks/useWalletService.ts @@ -13,6 +13,7 @@ import { UserContext } from '../UserContext' import { WalletService, WalletServiceSnapshot } from '../services/WalletService' import type { QueueSnapshot } from '../services/PermissionQueueManager' import type { PeerPaySnapshot } from '../services/PeerPayManager' +import { DEFAULT_PERMISSIONS_CONFIG } from '../WalletContext' // Module-level singleton — survives React re-renders and hot reloads let _walletServiceInstance: WalletService | null = null @@ -29,6 +30,19 @@ export function getWalletService(): WalletService { _walletServiceInstance = new WalletService() // Restore config from snapshot synchronously before first render _walletServiceInstance.restoreConfigFromSnapshot() + // Restore permissions config from localStorage BEFORE priming snapshot cache. + // Doing this in a React useEffect creates a race: the useEffect's emit fires + // before useSyncExternalStore's subscribe has registered, so the snapshot + // update is lost and React keeps reading the DEFAULT-primed cache. + try { + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('permissionsConfig') : null + if (stored) { + const merged = { ...DEFAULT_PERMISSIONS_CONFIG, ...JSON.parse(stored) } + _walletServiceInstance.permissionQueue.permissionsConfig = merged + } + } catch (e) { + console.error('[getWalletService] Failed to load permissionsConfig from localStorage:', e) + } // Prime the caches so getSnapshot functions never return null on first call _walletSnapshot = _walletServiceInstance.getSnapshot() _queueSnapshot = _walletServiceInstance.permissionQueue.getSnapshot() @@ -251,12 +265,24 @@ export function useWalletService() { const addBackupStorageUrl = useCallback((url: string) => svc.addBackupStorageUrl(url), [svc]) const removeBackupStorageUrl = useCallback((url: string) => svc.removeBackupStorageUrl(url), [svc]) const syncBackupStorage = useCallback((cb?: any) => svc.syncBackupStorage(cb), [svc]) + const setPrimaryStorage = useCallback( + (target: string, cb?: (message: string) => void) => svc.setPrimaryStorage(target, cb), + [svc] + ) const updateMessageBoxUrl = useCallback((url: string) => svc.updateMessageBoxUrl(url), [svc]) const removeMessageBoxUrl = useCallback(() => svc.removeMessageBoxUrl(), [svc]) const updateSettings = useCallback((s: any) => svc.updateSettings(s), [svc]) const updatePermissionsConfig = useCallback(async (config: any) => { - svc.permissionQueue.permissionsConfig = config - localStorage.setItem('permissionsConfig', JSON.stringify(config)) + // Persist first — if storage fails we don't want to silently update the + // in-memory config and have the change disappear on reload. + try { + localStorage.setItem('permissionsConfig', JSON.stringify(config)) + } catch (e) { + console.error('[useWalletService] failed to persist permissionsConfig:', e) + throw e + } + // Apply to queue + live WalletPermissionsManager and re-emit snapshot. + svc.permissionQueue.setPermissionsConfig(config) }, [svc]) const anointCurrentHost = useCallback( @@ -326,6 +352,9 @@ export function useWalletService() { // Managers managers: walletState.managers, updateManagers, + // Raw, unwrapped Wallet — for internal first-party use only. App-originated + // requests must go through managers.permissionsManager. + wallet: walletState.wallet, // Settings settings: walletState.settings, updateSettings, @@ -375,6 +404,7 @@ export function useWalletService() { addBackupStorageUrl, removeBackupStorageUrl, syncBackupStorage, + setPrimaryStorage, updateMessageBoxUrl, removeMessageBoxUrl, initializingBackendServices: walletState.initializingBackendServices, diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index 13a1b30..2406e2c 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -95,6 +95,8 @@ const en = { backup_storage_providers_label: 'Backup Storage Providers ({{count}})', backup_storage_local_electron: 'Local Electron Storage (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Remove', + backup_storage_make_primary_button: 'Make Primary', + backup_error_add_failed: 'Failed to add backup storage: {{message}}', backup_storage_add_button: 'Add Backup Storage', backup_storage_sync_button: 'Sync All Backups', backup_storage_sync_syncing: 'Syncing...', @@ -1077,6 +1079,8 @@ const es = { backup_storage_providers_label: 'Proveedores de Almacenamiento de Respaldo ({{count}})', backup_storage_local_electron: 'Almacenamiento Local Electron (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Eliminar', + backup_storage_make_primary_button: 'Hacer Primario', + backup_error_add_failed: 'Error al añadir almacenamiento de respaldo: {{message}}', backup_storage_add_button: 'Agregar Almacenamiento de Respaldo', backup_storage_sync_button: 'Sincronizar Todos los Respaldos', backup_storage_sync_syncing: 'Sincronizando...', @@ -2001,6 +2005,8 @@ const fr = { backup_storage_providers_label: 'Fournisseurs de Stockage de Sauvegarde ({{count}})', backup_storage_local_electron: 'Stockage Electron Local (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Supprimer', + backup_storage_make_primary_button: 'Définir comme Principal', + backup_error_add_failed: 'Échec de l\'ajout du stockage de sauvegarde : {{message}}', backup_storage_add_button: 'Ajouter un Stockage de Sauvegarde', backup_storage_sync_button: 'Synchroniser Toutes les Sauvegardes', backup_storage_sync_syncing: 'Synchronisation...', @@ -2949,6 +2955,8 @@ const pt = { backup_storage_providers_label: 'Provedores de Armazenamento de Backup ({{count}})', backup_storage_local_electron: 'Armazenamento Electron Local (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Remover', + backup_storage_make_primary_button: 'Tornar Primário', + backup_error_add_failed: 'Falha ao adicionar armazenamento de backup: {{message}}', backup_storage_add_button: 'Adicionar Armazenamento de Backup', backup_storage_sync_button: 'Sincronizar Todos os Backups', backup_storage_sync_syncing: 'Sincronizando...', @@ -3885,6 +3893,8 @@ const zh = { backup_storage_providers_label: '备份存储提供商({{count}})', backup_storage_local_electron: '本地 Electron 存储(~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: '移除', + backup_storage_make_primary_button: '设为主要', + backup_error_add_failed: '添加备份存储失败:{{message}}', backup_storage_add_button: '添加备份存储', backup_storage_sync_button: '同步所有备份', backup_storage_sync_syncing: '同步中...', @@ -4821,6 +4831,8 @@ const hi = { backup_storage_providers_label: 'बैकअप स्टोरेज प्रदाता ({{count}})', backup_storage_local_electron: 'स्थानीय Electron स्टोरेज (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'हटाएं', + backup_storage_make_primary_button: 'प्राथमिक बनाएं', + backup_error_add_failed: 'बैकअप स्टोरेज जोड़ने में विफल: {{message}}', backup_storage_add_button: 'बैकअप स्टोरेज जोड़ें', backup_storage_sync_button: 'सभी बैकअप सिंक करें', backup_storage_sync_syncing: 'सिंक हो रहा है...', @@ -5757,6 +5769,8 @@ const bn = { backup_storage_providers_label: 'ব্যাকআপ স্টোরেজ প্রদানকারী ({{count}})', backup_storage_local_electron: 'স্থানীয় Electron স্টোরেজ (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'সরান', + backup_storage_make_primary_button: 'প্রাথমিক করুন', + backup_error_add_failed: 'ব্যাকআপ স্টোরেজ যোগ করতে ব্যর্থ: {{message}}', backup_storage_add_button: 'ব্যাকআপ স্টোরেজ যোগ করুন', backup_storage_sync_button: 'সব ব্যাকআপ সিঙ্ক করুন', backup_storage_sync_syncing: 'সিঙ্ক হচ্ছে...', @@ -6693,6 +6707,8 @@ const ar = { backup_storage_providers_label: 'مزودو التخزين الاحتياطي ({{count}})', backup_storage_local_electron: 'التخزين المحلي Electron (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'إزالة', + backup_storage_make_primary_button: 'تعيين كأساسي', + backup_error_add_failed: 'فشل إضافة تخزين النسخة الاحتياطية: {{message}}', backup_storage_add_button: 'إضافة تخزين احتياطي', backup_storage_sync_button: 'مزامنة جميع النسخ الاحتياطية', backup_storage_sync_syncing: 'جارٍ المزامنة...', @@ -7629,6 +7645,8 @@ const ru = { backup_storage_providers_label: 'Провайдеры резервного хранилища ({{count}})', backup_storage_local_electron: 'Локальное хранилище Electron (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Удалить', + backup_storage_make_primary_button: 'Сделать основным', + backup_error_add_failed: 'Не удалось добавить резервное хранилище: {{message}}', backup_storage_add_button: 'Добавить резервное хранилище', backup_storage_sync_button: 'Синхронизировать все резервные копии', backup_storage_sync_syncing: 'Синхронизация...', @@ -8565,6 +8583,8 @@ const id = { backup_storage_providers_label: 'Penyedia Penyimpanan Cadangan ({{count}})', backup_storage_local_electron: 'Penyimpanan Lokal Electron (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Hapus', + backup_storage_make_primary_button: 'Jadikan Utama', + backup_error_add_failed: 'Gagal menambahkan penyimpanan cadangan: {{message}}', backup_storage_add_button: 'Tambah Penyimpanan Cadangan', backup_storage_sync_button: 'Sinkronkan Semua Cadangan', backup_storage_sync_syncing: 'Menyinkronkan...', @@ -9506,6 +9526,8 @@ const ja = { backup_storage_providers_label: 'バックアップストレージプロバイダー ({{count}})', backup_storage_local_electron: 'ローカルElectronストレージ (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: '削除', + backup_storage_make_primary_button: 'プライマリに設定', + backup_error_add_failed: 'バックアップストレージの追加に失敗しました: {{message}}', backup_storage_add_button: 'バックアップストレージを追加', backup_storage_sync_button: 'すべてのバックアップを同期', backup_storage_sync_syncing: '同期中...', @@ -10488,6 +10510,8 @@ const pl = { backup_storage_providers_label: 'Dostawcy kopii zapasowej ({{count}})', backup_storage_local_electron: 'Lokalna pamięć Electron (~/.bsv-desktop/wallet-*.db)', backup_storage_remove_button: 'Usuń', + backup_storage_make_primary_button: 'Ustaw jako główne', + backup_error_add_failed: 'Nie udało się dodać pamięci kopii zapasowej: {{message}}', backup_storage_add_button: 'Dodaj kopię zapasową', backup_storage_sync_button: 'Synchronizuj wszystkie kopie', backup_storage_sync_syncing: 'Synchronizuję...', diff --git a/src/lib/pages/Dashboard/Settings/WalletDiagnosis.tsx b/src/lib/pages/Dashboard/Settings/WalletDiagnosis.tsx index 0e8b7bb..e0a0df2 100644 --- a/src/lib/pages/Dashboard/Settings/WalletDiagnosis.tsx +++ b/src/lib/pages/Dashboard/Settings/WalletDiagnosis.tsx @@ -36,7 +36,7 @@ interface ConfirmationState { const WalletDiagnosis = () => { const { t } = useTranslation() - const { managers } = useContext(WalletContext) + const { wallet: rawWallet } = useContext(WalletContext) const [expanded, setExpanded] = useState(false) const [loading, setLoading] = useState(false) @@ -54,10 +54,9 @@ const WalletDiagnosis = () => { }, []) const getWallet = useCallback(() => { - const wallet = managers.wallet - if (!wallet) throw new Error('Wallet not available. Please ensure you are logged in.') - return wallet - }, [managers.wallet]) + if (!rawWallet) throw new Error('Wallet not available. Please ensure you are logged in.') + return rawWallet + }, [rawWallet]) const getWalletClass = useCallback((): Wallet => { const wallet = getWallet() diff --git a/src/lib/pages/Dashboard/Settings/index.tsx b/src/lib/pages/Dashboard/Settings/index.tsx index 8160a15..6356c79 100644 --- a/src/lib/pages/Dashboard/Settings/index.tsx +++ b/src/lib/pages/Dashboard/Settings/index.tsx @@ -78,7 +78,7 @@ const Settings = () => { const classes = useStyles() const { t } = useTranslation() const { currentLanguage, setCurrentLanguage, supportedLanguages } = useLanguage() - const { settings, updateSettings, wabUrl, useRemoteStorage, useMessageBox, storageUrl, useWab, messageBoxUrl, backupStorageUrls, addBackupStorageUrl, removeBackupStorageUrl, syncBackupStorage, permissionsConfig, updatePermissionsConfig } = useContext(WalletContext) + const { settings, updateSettings, wabUrl, useRemoteStorage, useMessageBox, storageUrl, useWab, messageBoxUrl, backupStorageUrls, addBackupStorageUrl, removeBackupStorageUrl, syncBackupStorage, setPrimaryStorage, permissionsConfig, updatePermissionsConfig } = useContext(WalletContext) const { pageLoaded, setManualUpdateInfo } = useContext(UserContext) const [settingsLoading, setSettingsLoading] = useState(false) const theme = useTheme() @@ -184,8 +184,9 @@ const Settings = () => { await addBackupStorageUrl(local ? 'LOCAL_STORAGE' : newBackupUrl); setShowBackupDialog(false); setNewBackupUrl(''); - } catch (e) { - // Error already shown by addBackupStorageUrl + } catch (e: any) { + console.error('[Settings] addBackupStorageUrl failed:', e); + toast.error(t('backup_error_add_failed', { message: e?.message || 'unknown error' })); } finally { setBackupLoading(false); } @@ -202,6 +203,29 @@ const Settings = () => { } } + const handleMakePrimary = async (target: string) => { + setSyncError(''); + setSyncProgressLogs([]); + setSyncComplete(false); + setShowSyncProgress(true); + setBackupLoading(true); + const progressCallback = (message: string) => { + for (const line of message.split('\n')) { + if (line.trim()) setSyncProgressLogs((prev) => [...prev, line]); + } + }; + try { + await setPrimaryStorage(target, progressCallback); + } catch (e: any) { + console.error('[Settings] setPrimaryStorage failed:', e); + setSyncError(e?.message || 'Failed to switch primary storage'); + } finally { + // Mark complete regardless of outcome so the dialog button flips from Cancel to Close. + setSyncComplete(true); + setBackupLoading(false); + } + } + const handleSyncBackupStorage = async () => { // Reset state setSyncError(''); @@ -571,6 +595,15 @@ const Settings = () => { }}> {url === 'LOCAL_STORAGE' ? t('backup_storage_local_electron') : url} +