| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Class to handle accessing/storing/caching local storage data. |
| */ |
| import {TestImportManager} from './testing/test_import_manager.js'; |
| |
| type StorageChange = chrome.storage.StorageChange; |
| |
| export class LocalStorage { |
| private values_: Record<string, any>|null = null; |
| private keyCallbacks_: Record<string, Array<(value: any) => void>> = {}; |
| private static instance?: LocalStorage; |
| |
| constructor(onInit: (localStorage: LocalStorage) => void) { |
| chrome.storage.local.get( |
| undefined /* get all values */, |
| (values: {[key: string]: any}) => this.onInitialGet_(values, onInit)); |
| chrome.storage.local.onChanged.addListener( |
| (updates: {[key: string]: StorageChange}) => this.update_(updates)); |
| } |
| |
| // ========== Static methods ========== |
| |
| static async init(): Promise<void> { |
| if (LocalStorage.instance) { |
| throw new Error( |
| 'LocalStorage.init() should be called at most once in each ' + |
| 'browser context.'); |
| } |
| |
| LocalStorage.instance = |
| await new Promise(resolve => new LocalStorage(resolve)); |
| LocalStorage.migrateFromLocalStorage_(); |
| } |
| |
| static addListenerForKey(key: string, callback: (value: any) => void): void { |
| // TODO(b/314203187): Not nulls asserted, check these to make sure they are |
| // correct. |
| if (!LocalStorage.instance!.keyCallbacks_[key]) { |
| LocalStorage.instance!.keyCallbacks_[key] = []; |
| } |
| if (callback) { |
| LocalStorage.instance!.keyCallbacks_[key].push(callback); |
| } |
| } |
| |
| static get(key: string, defaultValue: any = undefined): any { |
| LocalStorage.assertReady_(); |
| const value = LocalStorage.instance!.values_![key]; |
| if (value !== undefined) { |
| return value; |
| } |
| return defaultValue; |
| } |
| |
| static getTypeChecked(key: string, type: string, defaultValue?: any): any { |
| const value = LocalStorage.get(key, defaultValue); |
| if (typeof value === type) { |
| return value; |
| } |
| throw new Error( |
| 'Value in LocalStorage for key "' + key + '" is not a ' + type); |
| } |
| |
| static getBoolean(key: string, defaultValue?: boolean): boolean { |
| const value = LocalStorage.getTypeChecked(key, 'boolean', defaultValue); |
| return Boolean(value); |
| } |
| |
| static getNumber(key: string, defaultValue?: number): number { |
| const value = LocalStorage.getTypeChecked(key, 'number', defaultValue); |
| if (isNaN(value)) { |
| throw new Error('Value in LocalStorage for key "' + key + '" is NaN'); |
| } |
| return Number(value); |
| } |
| |
| static getString(key: string, defaultValue?: string): string { |
| const value = LocalStorage.getTypeChecked(key, 'string', defaultValue); |
| return String(value); |
| } |
| |
| static remove(key: string): void { |
| LocalStorage.assertReady_(); |
| chrome.storage.local.remove(key); |
| delete LocalStorage.instance!.values_![key]; |
| } |
| |
| static set(key: string, val: any): void { |
| LocalStorage.assertReady_(); |
| chrome.storage.local.set({[key]: val}); |
| LocalStorage.instance!.values_![key] = val; |
| } |
| |
| static toggle(key: string): void { |
| LocalStorage.assertReady_(); |
| const val = LocalStorage.get(key); |
| if (typeof val !== 'boolean') { |
| throw new Error('Cannot toggle value of non-boolean setting'); |
| } |
| LocalStorage.set(key, !val); |
| } |
| |
| // ========= Private Methods ========== |
| |
| private onInitialGet_(values: Record<string, any>, onInit: (localStorage: LocalStorage) => void): void { |
| this.values_ = values; |
| onInit(this); |
| } |
| |
| private update_(updates: Record<string, chrome.storage.StorageChange>): void { |
| for (const key in updates) { |
| // TODO(b/314203187): Not null asserted, check these to make sure they are |
| // correct. |
| this.values_![key] = updates[key].newValue; |
| if (this.keyCallbacks_[key]) { |
| this.keyCallbacks_[key].forEach( |
| callback => callback(updates[key].newValue)); |
| } |
| } |
| } |
| |
| private static migrateFromLocalStorage_(): void { |
| // Save the keys, because otherwise the values are shifting under us as we |
| // iterate. |
| const keys: string[] = []; |
| for (let i = 0; i < localStorage.length; i++) { |
| keys.push(localStorage.key(i)!); |
| } |
| |
| for (const key of keys) { |
| let val = localStorage[key]; |
| delete localStorage[key]; |
| |
| if (val === String(true)) { |
| val = true; |
| } else if (val === String(false)) { |
| val = false; |
| } else if (/^\d+$/.test(val)) { |
| // A string that with at least one digit and no other characters is an |
| // integer. |
| val = parseInt(val, 10); |
| } else if (/^[\d]+[.][\d]+$/.test(val)) { |
| // A string with at least one digit, followed by a dot, followed by at |
| // least one digit is a floating point number. |
| // |
| // When converting floats to strings, v8 adds the leading 0 if there |
| // were no digits before the decimal. E.g. String(.2) === "0.2" |
| // |
| // Similarly, integer values followed by a dot and any number of zeroes |
| // are stored without a decimal and will be handled by the above case. |
| // E.g. String(1.0) === "1" |
| val = parseFloat(val); |
| } else if (/^{.*}$/.test(val) || /^\[.*]$/.test(val)) { |
| // If a string begins and ends with curly or square brackets, try to |
| // convert it to an object/array. JSON.parse() will throw an error if |
| // the string is not valid JSON syntax. In that case, the variable value |
| // will remain unchanged (with a type of 'string'). |
| try { |
| val = JSON.parse(val); |
| } catch (syntaxError) { |
| } |
| } |
| |
| // We cannot call LocalStorage.set() because assertReady will fail. |
| chrome.storage.local.set({[key]: val}); |
| LocalStorage.instance!.values_![key] = val; |
| } |
| } |
| |
| private static assertReady_(): void { |
| if (!LocalStorage.instance || !LocalStorage.instance.values_) { |
| throw new Error( |
| 'LocalStorage should not be accessed until initialization is ' + |
| 'complete.'); |
| } |
| } |
| } |
| |
| TestImportManager.exportForTesting(LocalStorage); |