diff --git a/src/shared/storages/base.ts b/src/shared/storages/base.ts index 228b0ce6e..7007ab76c 100644 --- a/src/shared/storages/base.ts +++ b/src/shared/storages/base.ts @@ -1,10 +1,46 @@ +/** + * Storage area type for persisting and exchanging data. + * @see https://developer.chrome.com/docs/extensions/reference/storage/#overview + */ export enum StorageType { + /** + * Persist data locally against browser restarts. Will be deleted by uninstalling the extension. + * @default + */ Local = 'local', + /** + * Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply. + */ Sync = 'sync', + /** + * Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a + * json schema for company wide config. + */ Managed = 'managed', + /** + * Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and + * therefore need to restore their state. Set {@link SessionAccessLevel} for permitting content scripts access. + * @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) + */ Session = 'session', } +/** + * Global access level requirement for the {@link StorageType.Session} Storage Area. + * @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel) + */ +export enum SessionAccessLevel { + /** + * Storage can only be accessed by Extension pages (not Content scripts). + * @default + */ + ExtensionPagesOnly = 'TRUSTED_CONTEXTS', + /** + * Storage can be accessed by both Extension pages and Content scripts. + */ + ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS', +} + type ValueOrUpdate = D | ((prev: D) => Promise | D); export type BaseStorage = { @@ -14,15 +50,94 @@ export type BaseStorage = { subscribe: (listener: () => void) => () => void; }; -export function createStorage(key: string, fallback: D, config?: { storageType?: StorageType }): BaseStorage { +type StorageConfig = { + /** + * Assign the {@link StorageType} to use. + * @default Local + */ + storageType?: StorageType; + /** + * Only for {@link StorageType.Session}: Grant Content scripts access to storage area? + * @default false + */ + sessionAccessForContentScripts?: boolean; + /** + * Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts. + * To allow chrome background scripts to stay in sync as well, use {@link StorageType.Session} storage area with + * {@link StorageConfig.sessionAccessForContentScripts} potentially also set to true. + * @see https://stackoverflow.com/a/75637138/2763239 + * @default false + */ + liveUpdate?: boolean; +}; + +/** + * Sets or updates an arbitrary cache with a new value or the result of an update function. + */ +async function updateCache(valueOrUpdate: ValueOrUpdate, cache: D | null): Promise { + // Type guard to check if our value or update is a function + function isFunction(value: ValueOrUpdate): value is (prev: D) => D | Promise { + return typeof value === 'function'; + } + + // Type guard to check in case of a function, if its a Promise + function returnsPromise(func: (prev: D) => D | Promise): boolean { + // Use ReturnType to infer the return type of the function and check if it's a Promise + return (func as (prev: D) => Promise) instanceof Promise; + } + + if (isFunction(valueOrUpdate)) { + // Check if the function returns a Promise + if (returnsPromise(valueOrUpdate)) { + return await valueOrUpdate(cache); + } else { + return valueOrUpdate(cache); + } + } else { + return valueOrUpdate; + } +} + +/** + * If one session storage needs access from content scripts, we need to enable it globally. + * @default false + */ +let globalSessionAccessLevelFlag: StorageConfig['sessionAccessForContentScripts'] = false; + +/** + * Checks if the storage permission is granted in the manifest.json. + */ +function checkStoragePermission(storageType: StorageType): void { + if (chrome.storage[storageType] === undefined) { + throw new Error(`Check your storage permission in manifest.json: ${storageType} is not defined`); + } +} + +/** + * Creates a storage area for persisting and exchanging data. + */ +export function createStorage(key: string, fallback: D, config?: StorageConfig): BaseStorage { let cache: D | null = null; let listeners: Array<() => void> = []; const storageType = config?.storageType ?? StorageType.Local; + const liveUpdate = config?.liveUpdate ?? false; + // Set global session storage access level for StoryType.Session, only when not already done but needed. + if ( + globalSessionAccessLevelFlag === false && + storageType === StorageType.Session && + config?.sessionAccessForContentScripts === true + ) { + checkStoragePermission(storageType); + chrome.storage[storageType].setAccessLevel({ + accessLevel: SessionAccessLevel.ExtensionPagesAndContentScripts, + }); + globalSessionAccessLevelFlag = true; + } + + // Register life cycle methods const _getDataFromStorage = async (): Promise => { - if (chrome.storage[storageType] === undefined) { - throw new Error(`Check your storage permission into manifest.json: ${storageType} is not defined`); - } + checkStoragePermission(storageType); const value = await chrome.storage[storageType].get([key]); return value[key] ?? fallback; }; @@ -32,20 +147,8 @@ export function createStorage(key: string, fallback: D, config?: { storageTyp }; const set = async (valueOrUpdate: ValueOrUpdate) => { - if (typeof valueOrUpdate === 'function') { - // eslint-disable-next-line no-prototype-builtins - if (valueOrUpdate.hasOwnProperty('then')) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - cache = await valueOrUpdate(cache); - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - cache = valueOrUpdate(cache); - } - } else { - cache = valueOrUpdate; - } + cache = await updateCache(valueOrUpdate, cache); + await chrome.storage[storageType].set({ [key]: cache }); _emitChange(); }; @@ -66,6 +169,25 @@ export function createStorage(key: string, fallback: D, config?: { storageTyp _emitChange(); }); + // Listener for live updates from the browser + async function _updateFromStorageOnChanged(changes: { [key: string]: chrome.storage.StorageChange }) { + // Check if the key we are listening for is in the changes object + if (changes[key] === undefined) return; + + const valueOrUpdate: ValueOrUpdate = changes[key].newValue; + + if (cache === valueOrUpdate) return; + + cache = await updateCache(valueOrUpdate, cache); + + _emitChange(); + } + + // Register listener for live updates for our storage area + if (liveUpdate) { + chrome.storage[storageType].onChanged.addListener(_updateFromStorageOnChanged); + } + return { get: _getDataFromStorage, set, diff --git a/src/shared/storages/exampleThemeStorage.ts b/src/shared/storages/exampleThemeStorage.ts index b62e0454c..fbf96f114 100644 --- a/src/shared/storages/exampleThemeStorage.ts +++ b/src/shared/storages/exampleThemeStorage.ts @@ -8,6 +8,7 @@ type ThemeStorage = BaseStorage & { const storage = createStorage('theme-storage-key', 'light', { storageType: StorageType.Local, + liveUpdate: true, }); const exampleThemeStorage: ThemeStorage = {