Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/electron-chrome-extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
126 changes: 126 additions & 0 deletions packages/electron-chrome-extensions/src/browser/api/storage.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, any>>()

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<string, any> {
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<string, any> = {}

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<string, any> = {}

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<string, any> = {}

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<string, any>) => {
const store = this.getStore(extension.id)
const changes: Record<string, any> = {}

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
}
}
3 changes: 3 additions & 0 deletions packages/electron-chrome-extensions/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -128,6 +129,7 @@ export class ElectronChromeExtensions extends EventEmitter {
notifications: NotificationsAPI
permissions: PermissionsAPI
runtime: RuntimeAPI
storage: StorageAPI
tabs: TabsAPI
webNavigation: WebNavigationAPI
windows: WindowsAPI
Expand Down Expand Up @@ -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),
Expand Down
52 changes: 52 additions & 0 deletions packages/electron-chrome-extensions/src/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, chrome.storage.StorageChange>
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,
Expand Down
Loading