taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 1919bce72913319e5c3d6d795c2095bd108f6e86
parent 4204c8c64d437f70aad84f6814d02a9ad93cec28
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 00:00:49 +0200

web-util: remove browser storage sync and guard local storage

Also add tests.

Diffstat:
Mpackages/web-util/package.json | 1-
Apackages/web-util/src/hooks/useLocalStorage.test.tsx | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/hooks/useLocalStorage.ts | 57+++++++++++++++------------------------------------------
Apackages/web-util/src/utils/observable.test.ts | 29+++++++++++++++++++++++++++++
Mpackages/web-util/src/utils/observable.ts | 398+++++++++++++++++++++++++++++++++----------------------------------------------
Mpackages/web-util/tsconfig.json | 5++---
Mpnpm-lock.yaml | 11-----------
7 files changed, 427 insertions(+), 289 deletions(-)

diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -59,7 +59,6 @@ "ws": "7.4.5" }, "dependencies": { - "@types/chrome": "0.0.197", "qrcode-generator": "^1.4.4", "tailwindcss": "3.4.17" }, diff --git a/packages/web-util/src/hooks/useLocalStorage.test.tsx b/packages/web-util/src/hooks/useLocalStorage.test.tsx @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { act } from "preact/test-utils"; +import { buildStorageKey, useLocalStorage } from "./useLocalStorage.js"; + +function installDom(storage?: Storage): Window { + const window = new Window({ url: "https://storage.example/" }); + if (storage) { + Object.defineProperty(window, "localStorage", { + configurable: true, + value: storage, + }); + } + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + StorageEvent: window.StorageEvent, + HTMLElement: window.HTMLElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +function throwingWriteStorage(): Storage { + const content = new Map<string, string>(); + return { + get length() { + return content.size; + }, + clear() { + content.clear(); + }, + getItem(key: string) { + return content.get(key) ?? null; + }, + key(index: number) { + return [...content.keys()][index] ?? null; + }, + removeItem(key: string) { + content.delete(key); + }, + setItem() { + throw new DOMException("Storage quota exceeded", "QuotaExceededError"); + }, + }; +} + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +test("rapid updates always render the latest value", async () => { + const window = installDom(); + const originalDate = globalThis.Date; + class FixedDate extends originalDate { + constructor() { + super(1_000); + } + } + globalThis.Date = FixedDate as DateConstructor; + const { cleanup, render } = await import("@testing-library/preact"); + const key = buildStorageKey("rapid-update-test"); + let update = (_value: string): void => undefined; + + function Harness() { + const state = useLocalStorage(key, "initial"); + update = state.update; + return <output>{state.value}</output>; + } + + try { + const view = render(<Harness />); + await act(() => { + update("first"); + update("second"); + }); + assert.equal(view.getByText("second").textContent, "second"); + } finally { + cleanup(); + globalThis.Date = originalDate; + await window.happyDOM.abort(); + } +}); + +test("an update between render and subscription is observed", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + const key = buildStorageKey("subscription-race-test"); + window.localStorage.setItem(key.id, JSON.stringify("before")); + let changeDuringRender = true; + + function Harness() { + const state = useLocalStorage(key, "initial"); + if (changeDuringRender) { + changeDuringRender = false; + window.localStorage.setItem(key.id, JSON.stringify("after")); + } + return <output>{state.value}</output>; + } + + try { + const view = render(<Harness />); + await eventually(() => + assert.equal(view.getByText("after").textContent, "after"), + ); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); + +test("storage events update the subscribed value", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + const key = buildStorageKey("cross-window-update-test"); + + function Harness() { + const state = useLocalStorage(key, "initial"); + return <output>{state.value}</output>; + } + + try { + const view = render(<Harness />); + const value = JSON.stringify("from-another-window"); + window.localStorage.setItem(key.id, value); + await act(() => { + window.dispatchEvent( + new window.StorageEvent("storage", { + key: key.id, + newValue: value, + storageArea: window.localStorage, + }), + ); + }); + assert.equal( + view.getByText("from-another-window").textContent, + "from-another-window", + ); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); + +test("write failures fall back to in-memory storage", async () => { + const window = installDom(throwingWriteStorage()); + const { cleanup, render } = await import("@testing-library/preact"); + const key = buildStorageKey("write-failure-test"); + let update = (_value: string): void => undefined; + + function Harness() { + const state = useLocalStorage(key, "initial"); + update = state.update; + return <output>{state.value}</output>; + } + + try { + const view = render(<Harness />); + await act(() => update("session-only")); + assert.equal(view.getByText("session-only").textContent, "session-only"); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); + +test("an unavailable localStorage getter falls back to memory", async () => { + const window = installDom(); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new DOMException("Storage access denied", "SecurityError"); + }, + }); + const { cleanup, render } = await import("@testing-library/preact"); + const key = buildStorageKey("blocked-storage-test"); + let update = (_value: string): void => undefined; + + function Harness() { + const state = useLocalStorage(key, "initial"); + update = state.update; + return <output>{state.value}</output>; + } + + try { + const view = render(<Harness />); + await act(() => update("session-only")); + assert.equal(view.getByText("session-only").textContent, "session-only"); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts @@ -19,14 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, Codec, codecForString } from "@gnu-taler/taler-util"; +import { Codec, codecForString } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; -import { - ObservableMap, - browserStorageMap, - localStorageMap, - memoryMap, -} from "../utils/observable.js"; +import { localStorageMap } from "../utils/observable.js"; declare const opaque_StorageKey: unique symbol; @@ -57,32 +52,7 @@ export interface StorageState<Type = string> { reset: () => void; } -const supportLocalStorage = typeof window !== "undefined"; -const supportBrowserStorage = - typeof chrome !== "undefined" && typeof chrome.storage !== "undefined"; - -/** - * Build setting storage - */ -const storage: ObservableMap<string, string> = (function buildStorage() { - if (supportBrowserStorage) { - //browser storage is like local storage but - //with app sync. - //Works for almost every browser - if (supportLocalStorage) { - return browserStorageMap(localStorageMap()); - } else { - // service worker doesn't have local storage - return browserStorageMap(memoryMap<string>()); - } - } else if (supportLocalStorage) { - // fallback if browser is too old - return localStorageMap(); - } else { - // new need to save settings somewhere - return memoryMap<string>(); - } -})(); +const storage = localStorageMap(); //with initial value export function useLocalStorage<Type = string>( key: StorageKey<Type>, @@ -97,25 +67,28 @@ export function useLocalStorage<Type = string>( key: StorageKey<Type>, defaultValue?: Type, ): StorageState<Type> { - const current = convert(storage.get(key.id), key, defaultValue); + const renderedValue = storage.get(key.id); + const current = convert(renderedValue, key, defaultValue); - const [_, setStoredValue] = useState(AbsoluteTime.now().t_ms); + const [_, setVersion] = useState(0); useEffect(() => { - return storage.onUpdate(key.id, () => { - // const newValue = storage.get(key.id); - setStoredValue(AbsoluteTime.now().t_ms); + const unsubscribe = storage.onUpdate(key.id, () => { + setVersion((version) => version + 1); }); + // The value can change after render but before this effect subscribes. + // Re-read it after subscribing so that such an update is not lost. + if (storage.get(key.id) !== renderedValue) { + setVersion((version) => version + 1); + } + return unsubscribe; }, [key.id]); const setValue = (value?: Type): void => { if (value === undefined) { storage.delete(key.id); } else { - storage.set( - key.id, - key.codec ? JSON.stringify(value) : (value as string), - ); + storage.set(key.id, JSON.stringify(value)); } }; diff --git a/packages/web-util/src/utils/observable.test.ts b/packages/web-util/src/utils/observable.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { memoryMap } from "./observable.js"; + +test("memory maps notify key and map subscribers", () => { + const storage = memoryMap<string>(); + let keyUpdates = 0; + let anyUpdates = 0; + const unsubscribeKey = storage.onUpdate("selected", () => keyUpdates++); + const unsubscribeAny = storage.onAnyUpdate(() => anyUpdates++); + + storage.set("selected", "first"); + assert.equal(keyUpdates, 1); + assert.equal(anyUpdates, 1); + + storage.set("other", "second"); + assert.equal(keyUpdates, 1); + assert.equal(anyUpdates, 2); + + storage.clear(); + assert.equal(keyUpdates, 2); + assert.equal(anyUpdates, 3); + + unsubscribeKey(); + unsubscribeAny(); + storage.set("selected", "third"); + assert.equal(keyUpdates, 2); + assert.equal(anyUpdates, 3); +}); diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts @@ -3,43 +3,58 @@ export type ObservableMap<K, V> = Map<K, V> & { onUpdate: (key: string, callback: () => void) => () => void; }; +function createObservableCallbacks() { + const anyUpdateHandlers = new Set<() => void>(); + const keyUpdateHandlers = new Map<string, Set<() => void>>(); + + return { + onAnyUpdate(handler: () => void): () => void { + anyUpdateHandlers.add(handler); + return () => anyUpdateHandlers.delete(handler); + }, + onUpdate(key: string, handler: () => void): () => void { + const handlers = keyUpdateHandlers.get(key) ?? new Set<() => void>(); + handlers.add(handler); + keyUpdateHandlers.set(key, handlers); + return () => { + handlers.delete(handler); + if (handlers.size === 0) keyUpdateHandlers.delete(key); + }; + }, + notifyUpdate(key: string): void { + for (const handler of keyUpdateHandlers.get(key) ?? []) handler(); + for (const handler of anyUpdateHandlers) handler(); + }, + notifyClear(): void { + for (const handlers of keyUpdateHandlers.values()) { + for (const handler of handlers) handler(); + } + for (const handler of anyUpdateHandlers) handler(); + }, + }; +} + //FIXME: allow different type for different properties export function memoryMap<T>( backend: Map<string, T> = new Map<string, T>(), ): ObservableMap<string, T> { - const obs = new EventTarget(); + const callbacks = createObservableCallbacks(); const theMemoryMap: ObservableMap<string, T> = { - onAnyUpdate: (handler) => { - obs.addEventListener(`update`, handler); - obs.addEventListener(`clear`, handler); - return () => { - obs.removeEventListener(`update`, handler); - obs.removeEventListener(`clear`, handler); - }; - }, - onUpdate: (key, handler) => { - obs.addEventListener(`update-${key}`, handler); - obs.addEventListener(`clear`, handler); - return () => { - obs.removeEventListener(`update-${key}`, handler); - obs.removeEventListener(`clear`, handler); - }; - }, + onAnyUpdate: callbacks.onAnyUpdate, + onUpdate: callbacks.onUpdate, delete: (key: string) => { const result = backend.delete(key); - obs.dispatchEvent(new Event(`update-${key}`)); - obs.dispatchEvent(new Event(`update`)); + callbacks.notifyUpdate(key); return result; }, set: (key: string, value: T) => { backend.set(key, value); - obs.dispatchEvent(new Event(`update-${key}`)); - obs.dispatchEvent(new Event(`update`)); + callbacks.notifyUpdate(key); return theMemoryMap; }, clear: () => { backend.clear(); - obs.dispatchEvent(new Event(`clear`)); + callbacks.notifyClear(); }, entries: backend.entries.bind(backend), forEach: backend.forEach.bind(backend), @@ -56,148 +71,161 @@ export function memoryMap<T>( return theMemoryMap; } -//FIXME: change this implementation to match the -// browser storage. instead of creating a sync implementation -// of observable map it should reuse the memoryMap and -// sync the state with local storage -export function localStorageMap(): ObservableMap<string, string> { - const obs = new EventTarget(); +type StorageProvider = () => Storage | undefined; + +function defaultStorageProvider(): Storage | undefined { + if (typeof window === "undefined") return undefined; + return window.localStorage; +} + +/** + * An observable local-storage map with an in-memory fallback. + * + * Browser storage can be unavailable or start throwing at any time, for + * example in privacy-restricted contexts or after exceeding its quota. The + * fallback keeps the UI operational for the remainder of the page session. + */ +export function localStorageMap( + storageProvider: StorageProvider = defaultStorageProvider, +): ObservableMap<string, string> { + const callbacks = createObservableCallbacks(); + const fallback = new Map<string, string>(); + let disabledBackend: Storage | undefined; + + function getBackend(): Storage | undefined { + try { + const backend = storageProvider(); + return backend === disabledBackend ? undefined : backend; + } catch { + return undefined; + } + } + + function disableBackend(backend: Storage): void { + disabledBackend = backend; + } + + function get(key: string): string | undefined { + const backend = getBackend(); + if (!backend) return fallback.get(key); + try { + const value = backend.getItem(key); + if (value === null) { + fallback.delete(key); + return undefined; + } + fallback.set(key, value); + return value; + } catch { + disableBackend(backend); + return fallback.get(key); + } + } + + function snapshot(): Map<string, string> { + const backend = getBackend(); + if (!backend) return new Map(fallback); + try { + const content = new Map<string, string>(); + for (let index = 0; index < backend.length; index++) { + const key = backend.key(index); + if (key === null) continue; + const value = backend.getItem(key); + if (value !== null) content.set(key, value); + } + fallback.clear(); + for (const [key, value] of content) fallback.set(key, value); + return content; + } catch { + disableBackend(backend); + return new Map(fallback); + } + } + + function storageEventMatches(event: StorageEvent): boolean { + if (event.storageArea === null) return true; + return event.storageArea === getBackend(); + } + const theLocalStorageMap: ObservableMap<string, string> = { onAnyUpdate: (handler) => { - obs.addEventListener(`update`, handler); - obs.addEventListener(`clear`, handler); - window.addEventListener("storage", handler); + const eventWindow = typeof window === "undefined" ? undefined : window; + function handleStorageEvent(event: StorageEvent) { + if (storageEventMatches(event)) handler(); + } + const unsubscribe = callbacks.onAnyUpdate(handler); + eventWindow?.addEventListener("storage", handleStorageEvent); return () => { - window.removeEventListener("storage", handler); - obs.removeEventListener(`update`, handler); - obs.removeEventListener(`clear`, handler); + eventWindow?.removeEventListener("storage", handleStorageEvent); + unsubscribe(); }; }, onUpdate: (key, handler) => { - obs.addEventListener(`update-${key}`, handler); - obs.addEventListener(`clear`, handler); function handleStorageEvent(ev: StorageEvent) { - if (ev.key === null || ev.key === key) { + if (storageEventMatches(ev) && (ev.key === null || ev.key === key)) { handler(); } } - window.addEventListener("storage", handleStorageEvent); + const eventWindow = typeof window === "undefined" ? undefined : window; + const unsubscribe = callbacks.onUpdate(key, handler); + eventWindow?.addEventListener("storage", handleStorageEvent); return () => { - window.removeEventListener("storage", handleStorageEvent); - obs.removeEventListener(`update-${key}`, handler); - obs.removeEventListener(`clear`, handler); + eventWindow?.removeEventListener("storage", handleStorageEvent); + unsubscribe(); }; }, delete: (key: string) => { - const exists = localStorage.getItem(key) !== null; - localStorage.removeItem(key); - obs.dispatchEvent(new Event(`update-${key}`)); - obs.dispatchEvent(new Event(`update`)); + const exists = get(key) !== undefined; + fallback.delete(key); + const backend = getBackend(); + if (backend) { + try { + backend.removeItem(key); + } catch { + disableBackend(backend); + } + } + callbacks.notifyUpdate(key); return exists; }, set: (key: string, v: string) => { - localStorage.setItem(key, v); - obs.dispatchEvent(new Event(`update-${key}`)); - obs.dispatchEvent(new Event(`update`)); + fallback.set(key, v); + const backend = getBackend(); + if (backend) { + try { + backend.setItem(key, v); + } catch { + disableBackend(backend); + } + } + callbacks.notifyUpdate(key); return theLocalStorageMap; }, clear: () => { - localStorage.clear(); - obs.dispatchEvent(new Event(`clear`)); - }, - entries: (): IterableIterator<[string, string]> => { - let index = 0; - const total = localStorage.length; - return { - next() { - if (index === total) return { done: true, value: undefined }; - const key = localStorage.key(index); - if (key === null) { - //we are going from 0 until last, this should not happen - throw Error("key cant be null"); - } - const item = localStorage.getItem(key); - if (item === null) { - //the key exist, this should not happen - throw Error("value cant be null"); - } - index = index + 1; - return { done: false, value: [key, item] }; - }, - [Symbol.iterator]() { - return this; - }, - }; - }, - forEach: (cb) => { - for (let index = 0; index < localStorage.length; index++) { - const key = localStorage.key(index); - if (key === null) { - //we are going from 0 until last, this should not happen - throw Error("key cant be null"); + fallback.clear(); + const backend = getBackend(); + if (backend) { + try { + backend.clear(); + } catch { + disableBackend(backend); } - const item = localStorage.getItem(key); - if (item === null) { - //the key exist, this should not happen - throw Error("value cant be null"); - } - cb(item, key, theLocalStorageMap); } + callbacks.notifyClear(); }, - get: (key: string) => { - const item = localStorage.getItem(key); - if (item === null) return undefined; - return item; - }, - has: (key: string) => { - return localStorage.getItem(key) !== null; - }, - keys: () => { - let index = 0; - const total = localStorage.length; - return { - next() { - if (index === total) return { done: true, value: undefined }; - const key = localStorage.key(index); - if (key === null) { - //we are going from 0 until last, this should not happen - throw Error("key cant be null"); - } - index = index + 1; - return { done: false, value: key }; - }, - [Symbol.iterator]() { - return this; - }, - }; + entries: () => snapshot().entries(), + forEach: (cb, thisArg) => { + snapshot().forEach((value, key) => + cb.call(thisArg, value, key, theLocalStorageMap), + ); }, + get, + has: (key: string) => get(key) !== undefined, + keys: () => snapshot().keys(), get size() { - return localStorage.length; - }, - values: () => { - let index = 0; - const total = localStorage.length; - return { - next() { - if (index === total) return { done: true, value: undefined }; - const key = localStorage.key(index); - if (key === null) { - //we are going from 0 until last, this should not happen - throw Error("key cant be null"); - } - const item = localStorage.getItem(key); - if (item === null) { - //the key exist, this should not happen - throw Error("value cant be null"); - } - index = index + 1; - return { done: false, value: item }; - }, - [Symbol.iterator]() { - return this; - }, - }; + return snapshot().size; }, + values: () => snapshot().values(), [Symbol.iterator]: function (): IterableIterator<[string, string]> { return theLocalStorageMap.entries(); }, @@ -205,97 +233,3 @@ export function localStorageMap(): ObservableMap<string, string> { }; return theLocalStorageMap; } - -const isFirefox = - typeof (window as any) !== "undefined" && - typeof (window as any)["InstallTrigger"] !== "undefined"; - -async function getAllContent() { - //Firefox and Chrome has different storage api - if (isFirefox) { - // @ts-ignore - return browser.storage.local.get(); - } else { - return chrome.storage.local.get(); - } -} - -async function updateContent(obj: Record<string, any>) { - if (isFirefox) { - // @ts-ignore - return browser.storage.local.set(obj); - } else { - return chrome.storage.local.set(obj); - } -} -async function removeContent(keys: string[]) { - if (keys.length === 0) return; - if (isFirefox) { - // @ts-ignore - return browser.storage.local.remove(keys); - } - return chrome.storage.local.remove(keys); -} -type Changes = { [key: string]: { oldValue?: any; newValue?: any } }; -function onBrowserStorageUpdate(cb: (changes: Changes) => void): void { - if (isFirefox) { - // @ts-ignore - browser.storage.local.onChanged.addListener(cb); - } else { - chrome.storage.local.onChanged.addListener(cb); - } -} - -export function browserStorageMap( - backend: ObservableMap<string, string>, -): ObservableMap<string, string> { - let applyingRemoteChange = false; - getAllContent().then((content) => { - applyingRemoteChange = true; - Object.entries(content ?? {}).forEach(([k, v]) => { - backend.set(k, v as string); - }); - applyingRemoteChange = false; - }); - - let pendingSync = Promise.resolve(); - backend.onAnyUpdate(() => { - if (applyingRemoteChange) return; - pendingSync = pendingSync - .then(async () => { - const result: Record<string, string> = {}; - for (const [key, value] of backend.entries()) result[key] = value; - const existing = await getAllContent(); - const deletedKeys = Object.keys(existing ?? {}).filter( - (key) => !(key in result), - ); - await removeContent(deletedKeys); - await updateContent(result); - }) - .catch((error) => { - console.error("Could not synchronize browser storage", error); - }); - }); - - onBrowserStorageUpdate((changes) => { - //another chrome instance made the change - applyingRemoteChange = true; - const changedItems = Object.keys(changes); - if (changedItems.length === 0) { - backend.clear(); - } else { - for (const key of changedItems) { - if (changes[key].newValue === undefined) { - backend.delete(key); - } else { - if (changes[key].newValue !== changes[key].oldValue) { - backend.set(key, changes[key].newValue); - } - } - } - } - applyingRemoteChange = false; - }); - - return backend; -} diff --git a/packages/web-util/tsconfig.json b/packages/web-util/tsconfig.json @@ -7,10 +7,9 @@ "lib": ["DOM", "ES2023"], "outDir": "lib", "paths": { - "@assets/*": ["../../contrib/taler-assets/*"], + "@assets/*": ["../../contrib/taler-assets/*"] }, - "rootDir": "./src", - "types": ["chrome"], + "rootDir": "./src" }, "references": [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -755,9 +755,6 @@ importers: packages/web-util: dependencies: - '@types/chrome': - specifier: 0.0.197 - version: 0.0.197 qrcode-generator: specifier: ^1.4.4 version: 1.4.4 @@ -1413,9 +1410,6 @@ packages: '@types/chai@4.3.3': resolution: {integrity: sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==} - '@types/chrome@0.0.197': - resolution: {integrity: sha512-m1NfS5bOjaypyqQfaX6CxmJodZVcvj5+Mt/K94EBHkflYjPNmXHAzbxfifdLMa0YM3PDyOxohoTS5ug/e6p5jA==} - '@types/chrome@0.0.326': resolution: {integrity: sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw==} @@ -4600,11 +4594,6 @@ snapshots: '@types/chai@4.3.3': {} - '@types/chrome@0.0.197': - dependencies: - '@types/filesystem': 0.0.32 - '@types/har-format': 1.2.9 - '@types/chrome@0.0.326': dependencies: '@types/filesystem': 0.0.32