diff --git a/packages/electron-chrome-extensions/README.md b/packages/electron-chrome-extensions/README.md index 81afbf46..b5f21c71 100644 --- a/packages/electron-chrome-extensions/README.md +++ b/packages/electron-chrome-extensions/README.md @@ -406,6 +406,7 @@ See [Electron's Notification tutorial](https://www.electronjs.org/docs/tutorial/ - [x] chrome.storage.local - [x] chrome.storage.managed - fallback to `local` +- [x] chrome.storage.session - [x] chrome.storage.sync - fallback to `local` ### [`chrome.tabs`](https://developer.chrome.com/extensions/tabs) diff --git a/packages/electron-chrome-extensions/src/browser/api/storage.ts b/packages/electron-chrome-extensions/src/browser/api/storage.ts new file mode 100644 index 00000000..53354bf6 --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/storage.ts @@ -0,0 +1,126 @@ +import { ExtensionContext } from '../context' +import { ExtensionEvent } from '../router' + +/** + * In-memory implementation of chrome.storage.session for MV3 extensions. + * Data is stored per-extension in the main process so it: + * - Persists across service worker restarts + * - Is shared between all extension contexts (background, content scripts, popup) + * - Is cleared when the extension is unloaded or the browser quits + */ +export class StorageAPI { + /** Per-extension session storage: extensionId -> key -> value */ + private sessionData = new Map>() + + constructor(private ctx: ExtensionContext) { + const handle = this.ctx.router.apiHandler() + + handle('storage.session.clear', this.sessionClear) + handle('storage.session.get', this.sessionGet) + handle('storage.session.getBytesInUse', this.sessionGetBytesInUse) + handle('storage.session.getKeys', this.sessionGetKeys) + handle('storage.session.remove', this.sessionRemove) + handle('storage.session.set', this.sessionSet) + handle('storage.session.setAccessLevel', this.sessionSetAccessLevel) + + const sessionExtensions = ctx.session.extensions || ctx.session + sessionExtensions.on('extension-unloaded', (_event, extension) => { + this.sessionData.delete(extension.id) + }) + } + + private getStore(extensionId: string): Record { + let store = this.sessionData.get(extensionId) + if (!store) { + store = {} + this.sessionData.set(extensionId, store) + } + return store + } + + private sessionClear = ({ extension }: ExtensionEvent) => { + const store = this.getStore(extension.id) + const changes: Record = {} + + for (const [key, value] of Object.entries(store)) { + changes[key] = { oldValue: value } + } + this.sessionData.set(extension.id, {}) + + if (Object.keys(changes).length > 0) { + this.ctx.router.broadcastEvent('storage.session.onChanged', changes) + } + } + + private sessionGet = ({ extension }: ExtensionEvent, keys: any) => { + const store = this.getStore(extension.id) + const result: Record = {} + + const keyList = + keys == null + ? Object.keys(store) + : typeof keys === 'string' + ? [keys] + : Array.isArray(keys) + ? keys + : Object.keys(keys) + + for (const key of keyList) { + if (key in store) { + result[key] = store[key] + } else if (keys && typeof keys === 'object' && !Array.isArray(keys) && key in keys) { + result[key] = keys[key] + } + } + + return result + } + + private sessionGetBytesInUse = ({ extension }: ExtensionEvent) => { + const store = this.getStore(extension.id) + return Object.keys(store).reduce((acc, key) => { + return acc + key.length + JSON.stringify(store[key]).length + }, 0) + } + + private sessionGetKeys = ({ extension }: ExtensionEvent) => { + const store = this.getStore(extension.id) + return Object.keys(store) + } + + private sessionRemove = ({ extension }: ExtensionEvent, keys: string | string[]) => { + const store = this.getStore(extension.id) + const keyList = typeof keys === 'string' ? [keys] : keys + const changes: Record = {} + + for (const key of keyList) { + if (key in store) { + changes[key] = { oldValue: store[key] } + delete store[key] + } + } + + if (Object.keys(changes).length > 0) { + this.ctx.router.broadcastEvent('storage.session.onChanged', changes) + } + } + + private sessionSet = ({ extension }: ExtensionEvent, items: Record) => { + const store = this.getStore(extension.id) + const changes: Record = {} + + for (const [key, value] of Object.entries(items)) { + const oldValue = store[key] + store[key] = value + changes[key] = { newValue: value, ...(oldValue !== undefined && { oldValue }) } + } + + if (Object.keys(changes).length > 0) { + this.ctx.router.broadcastEvent('storage.session.onChanged', changes) + } + } + + private sessionSetAccessLevel = () => { + // No-op + } +} diff --git a/packages/electron-chrome-extensions/src/browser/index.ts b/packages/electron-chrome-extensions/src/browser/index.ts index d2e6a0a9..ebabd67d 100644 --- a/packages/electron-chrome-extensions/src/browser/index.ts +++ b/packages/electron-chrome-extensions/src/browser/index.ts @@ -20,6 +20,7 @@ import { ExtensionRouter } from './router' import { checkLicense, License } from './license' import { readLoadedExtensionManifest } from './manifest' import { PermissionsAPI } from './api/permissions' +import { StorageAPI } from './api/storage' import { resolvePartition } from './partition' function checkVersion() { @@ -128,6 +129,7 @@ export class ElectronChromeExtensions extends EventEmitter { notifications: NotificationsAPI permissions: PermissionsAPI runtime: RuntimeAPI + storage: StorageAPI tabs: TabsAPI webNavigation: WebNavigationAPI windows: WindowsAPI @@ -165,6 +167,7 @@ export class ElectronChromeExtensions extends EventEmitter { notifications: new NotificationsAPI(this.ctx), permissions: new PermissionsAPI(this.ctx), runtime: new RuntimeAPI(this.ctx), + storage: new StorageAPI(this.ctx), tabs: new TabsAPI(this.ctx), webNavigation: new WebNavigationAPI(this.ctx), windows: new WindowsAPI(this.ctx), diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 78770e54..9e8bb02f 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -523,8 +523,60 @@ export const injectExtensionAPIs = () => { storage: { factory: (base) => { const local = base && base.local + + // chrome.storage.session implementation backed by the main process. + // Electron does not natively support chrome.storage.session. + const session = { + onChanged: new ExtensionEvent('storage.session.onChanged'), + get: invokeExtension('storage.session.get'), + set: invokeExtension('storage.session.set'), + remove: invokeExtension('storage.session.remove'), + clear: invokeExtension('storage.session.clear'), + getKeys: invokeExtension('storage.session.getKeys'), + getBytesInUse: invokeExtension('storage.session.getBytesInUse'), + setAccessLevel: invokeExtension('storage.session.setAccessLevel'), + QUOTA_BYTES: 10485760, + } + + // Make storage.onChanged subscribe to storage.session.onChanged too. + const onChanged = base.onChanged as chrome.storage.StorageChangedEvent | undefined + if (onChanged) { + type StorageChangeRecord = Record + type StorageCallback = ( + changes: StorageChangeRecord, + areaName: 'local' | 'sync' | 'managed' | 'session', + ) => void + + let sessionListener: StorageCallback | undefined + + const originalAddListener = onChanged.addListener.bind(onChanged) + onChanged.addListener = (callback: StorageCallback) => { + // Connect to additional onChanged listeners. + const hasListeners = onChanged.hasListeners() + if (!hasListeners) { + sessionListener = (changes: StorageChangeRecord) => callback(changes, 'session') + session.onChanged.addListener(sessionListener) + } + + originalAddListener(callback) + } + + const originalRemoveListener = onChanged.removeListener.bind(onChanged) + onChanged.removeListener = (callback: StorageCallback) => { + originalRemoveListener(callback) + + // Disconnect from additional onChanged listeners. + const hasListeners = onChanged.hasListeners() + if (!hasListeners && sessionListener) { + session.onChanged.removeListener(sessionListener) + sessionListener = undefined + } + } + } + return { ...base, + session, // TODO: provide a backend for browsers to opt-in to managed: local, sync: local,