diff --git a/src/composables/accounts.ts b/src/composables/accounts.ts index b64eda7e1..dcd3fcaf2 100644 --- a/src/composables/accounts.ts +++ b/src/composables/accounts.ts @@ -20,6 +20,8 @@ import { watchUntilTruthy, } from '@/utils'; import { ProtocolAdapterFactory } from '@/lib/ProtocolAdapterFactory'; +import migrateAccountsVuexToComposable from '@/migrations/001-accounts-vuex-to-composable'; +import migrateMnemonicVuexToComposable from '@/migrations/002-mnemonic-vuex-to-composable'; import { useStorageRef } from './composablesHelpers'; let isInitialized = false; @@ -31,13 +33,23 @@ let isInitialized = false; const mnemonic = useStorageRef( '', STORAGE_KEYS.mnemonic, - { backgroundSync: true }, + { + backgroundSync: true, + migrations: [ + migrateMnemonicVuexToComposable, + ], + }, ); const accountsRaw = useStorageRef( [], STORAGE_KEYS.accountsRaw, - { backgroundSync: true }, + { + backgroundSync: true, + migrations: [ + migrateAccountsVuexToComposable, + ], + }, ); const activeAccountGlobalIdx = useStorageRef( diff --git a/src/composables/composablesHelpers.ts b/src/composables/composablesHelpers.ts index a75da1282..fc2773ab8 100644 --- a/src/composables/composablesHelpers.ts +++ b/src/composables/composablesHelpers.ts @@ -7,7 +7,8 @@ import { watch, } from 'vue'; import { isEqual } from 'lodash-es'; -import type { StorageKey } from '@/types'; +import type { Migration, StorageKey } from '@/types'; +import { asyncPipe } from '@/utils'; import { WalletStorage } from '@/lib/WalletStorage'; import { useConnection } from './connection'; import { useUi } from './ui'; @@ -21,9 +22,10 @@ interface ICreateStorageRefOptions { * Callbacks run on the data that will be saved and read from the browser storage. */ serializer?: { - read: (v: T) => any, - write: (v: T) => any, + read: (v: T) => any; + write: (v: T) => any; }; + migrations?: Migration[]; } /** @@ -39,13 +41,13 @@ export function useStorageRef( const { serializer, backgroundSync = false, + migrations, } = options; - let isRestored = false; let watcherDisabled = false; // Avoid watcher going infinite loop const state = ref(initialState) as Ref; // https://github.com/vuejs/core/issues/2136 - function setState(val: any) { + function setLocalState(val: T | null) { if (val) { watcherDisabled = true; state.value = (serializer?.read) ? serializer.read(val) : val; @@ -53,29 +55,38 @@ export function useStorageRef( } } - watch(state, (val, oldVal) => { - // Arrays are not compared as there is a bug which makes the new and old val always the same. - if (!watcherDisabled && (Array.isArray(initialState) || !isEqual(val, oldVal))) { - WalletStorage.set(storageKey, (serializer?.write) ? serializer.write(val) : val); - } - }, { deep: true }); - - /** - * Two way binding between the extension and the background - * Whenever the app saves the state to browser storage the extension background picks this - * and synchronizes own state with the change. - */ - if (backgroundSync) { - WalletStorage.watch?.(storageKey, (val) => setState(val)); + function setStorageState(val: T | null) { + WalletStorage.set(storageKey, (val && serializer?.write) ? serializer.write(val) : val); } - if (!isRestored) { - (async () => { - const restoredValue = await WalletStorage.get(storageKey); - setState(restoredValue); - isRestored = true; - })(); - } + // Restore state and run watchers + (async () => { + let restoredValue = await WalletStorage.get(storageKey); + if (migrations?.length) { + restoredValue = await asyncPipe(migrations)(restoredValue); + setStorageState(restoredValue); + } + setLocalState(restoredValue); + + /** + * Synchronize the state value with the storage. + */ + watch(state, (val, oldVal) => { + // Arrays are not compared as there is a bug which makes the new and old val always the same. + if (!watcherDisabled && (Array.isArray(initialState) || !isEqual(val, oldVal))) { + setStorageState(val); + } + }, { deep: true }); + + /** + * Two way binding between the extension and the background + * Whenever the app saves the state to browser storage the extension background picks this + * and synchronizes own state with the change. + */ + if (backgroundSync) { + WalletStorage.watch?.(storageKey, (val) => setLocalState(val)); + } + })(); return state; } diff --git a/src/migrations/001-accounts-vuex-to-composable.ts b/src/migrations/001-accounts-vuex-to-composable.ts new file mode 100644 index 000000000..18a693496 --- /dev/null +++ b/src/migrations/001-accounts-vuex-to-composable.ts @@ -0,0 +1,27 @@ +import type { IAccountRaw, Migration } from '@/types'; +import { ACCOUNT_HD_WALLET, PROTOCOLS } from '@/constants'; +import { collectVuexState } from './migrationHelpers'; + +const migration: Migration = async (restoredValue: IAccountRaw[]) => { + if (!restoredValue?.length) { + const accounts = (await collectVuexState())?.accounts?.list as any[] | undefined; + if (accounts?.length) { + return accounts.reduce( + (list: IAccountRaw[], { protocol, type }: IAccountRaw) => { + if (PROTOCOLS.includes(protocol) && type === ACCOUNT_HD_WALLET) { + list.push({ + isRestored: true, + protocol, + type, + }); + } + return list; + }, + [], + ); + } + } + return restoredValue; +}; + +export default migration; diff --git a/src/migrations/002-mnemonic-vuex-to-composable.ts b/src/migrations/002-mnemonic-vuex-to-composable.ts new file mode 100644 index 000000000..a028f99f0 --- /dev/null +++ b/src/migrations/002-mnemonic-vuex-to-composable.ts @@ -0,0 +1,15 @@ +import { validateMnemonic } from '@aeternity/bip39'; +import type { Migration } from '@/types'; +import { collectVuexState } from './migrationHelpers'; + +const migration: Migration = async (restoredValue: string) => { + if (!restoredValue) { + const mnemonic = (await collectVuexState())?.mnemonic; + if (mnemonic && validateMnemonic(mnemonic)) { + return mnemonic; + } + } + return restoredValue; +}; + +export default migration; diff --git a/src/migrations/migrationHelpers.ts b/src/migrations/migrationHelpers.ts new file mode 100644 index 000000000..6a61e497e --- /dev/null +++ b/src/migrations/migrationHelpers.ts @@ -0,0 +1,23 @@ +import { watchUntilTruthy } from '@/utils'; +import { ref } from 'vue'; + +/** + * Before version 2.0.2 we were using Vuex and the whole state was kept as + * one browser/local storage entry. + */ +export const collectVuexState = (() => { + let vuexState: Record | null; + const isCollecting = ref(false); + return async () => { + if (window?.browser) { + if (isCollecting.value) { + await watchUntilTruthy(isCollecting); + } else if (!vuexState) { + isCollecting.value = true; + vuexState = (await window.browser.storage.local.get('state'))?.state as any; + isCollecting.value = false; + } + } + return vuexState; + }; +})(); diff --git a/src/types/index.ts b/src/types/index.ts index 8b110e92e..da0813153 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -668,3 +668,5 @@ export interface IFormSelectOption { text: string; value: string | number; } + +export type Migration = (restoredValue: T | any) => Promise; diff --git a/src/utils/common.ts b/src/utils/common.ts index c8279d264..384853848 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -222,6 +222,16 @@ export function pipe(fns: ((data: T) => T)[]) { return (data: T) => fns.reduce((currData, func) => func(currData), data); } +/** + * Run asynchronous callbacks one by one and pass previous returned value to the next one. + */ +export function asyncPipe(fns: ((data: T) => PromiseLike)[]) { + return (data: T): Promise => fns.reduce( + async (currData, func) => func(await currData), + Promise.resolve(data), + ); +} + export function prepareStorageKey(keys: string[]) { return [LOCAL_STORAGE_PREFIX, ...keys].join('_'); }