Skip to content

Commit

Permalink
feat: load all chrome web store extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmaddock committed Nov 26, 2024
1 parent cee492e commit 0a08342
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 125 deletions.
31 changes: 31 additions & 0 deletions packages/electron-chrome-web-store/src/browser/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createHash } from 'node:crypto'

/**
* Converts a normal hexadecimal string into the alphabet used by extensions.
* We use the characters 'a'-'p' instead of '0'-'f' to avoid ever having a
* completely numeric host, since some software interprets that as an IP address.
*
* @param id - The hexadecimal string to convert. This is modified in place.
*/
function convertHexadecimalToIDAlphabet(id: string) {
let result = ''
for (const ch of id) {
const val = parseInt(ch, 16)
if (!isNaN(val)) {
result += String.fromCharCode('a'.charCodeAt(0) + val)
} else {
result += 'a'
}
}
return result
}

function generateIdFromHash(hash: Buffer): string {
const hashedId = hash.subarray(0, 16).toString('hex')
return convertHexadecimalToIDAlphabet(hashedId)
}

export function generateId(input: string): string {
const hash = createHash('sha256').update(input, 'base64').digest()
return generateIdFromHash(hash)
}
134 changes: 81 additions & 53 deletions packages/electron-chrome-web-store/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
Result,
WebGlStatus,
} from '../common/constants'
import { loadAllExtensions } from './loader'
export { loadAllExtensions } from './loader'

const d = require('debug')('electron-chrome-web-store')
const AdmZip = require('adm-zip')
Expand Down Expand Up @@ -288,57 +290,7 @@ async function beginInstall(state: WebStoreState, details: InstallDetails) {
}
}

interface ElectronChromeWebStoreOptions {
/**
* Session to enable the Chrome Web Store in.
* Defaults to session.defaultSession
*/
session?: Electron.Session

/**
* Path to the 'electron-chrome-web-store' module.
*/
modulePath?: string

/**
* Path to extensions directory.
* Defaults to 'Extensions/' under userData path.
*/
extensionsPath?: string

/**
* List of allowed extension IDs to install.
*/
allowlist?: ExtensionId[]

/**
* List of denied extension IDs to install.
*/
denylist?: ExtensionId[]
}

/**
* Install Chrome Web Store support.
*
* @param options Chrome Web Store configuration options.
*/
export function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) {
const session = opts.session || electronSession.defaultSession
const extensionsPath = opts.extensionsPath || path.join(app.getPath('userData'), 'Extensions')
const modulePath = opts.modulePath || __dirname

const webStoreState: WebStoreState = {
session,
extensionsPath,
installing: new Set(),
allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined,
denylist: opts.denylist ? new Set(opts.denylist) : undefined,
}

// Add preload script to session
const preloadPath = path.join(modulePath, 'dist/renderer/web-store-preload.js')
session.setPreloads([...session.getPreloads(), preloadPath])

function addIpcListeners(webStoreState: WebStoreState) {
/** Handle IPCs from the Chrome Web Store. */
const handle = (
channel: string,
Expand Down Expand Up @@ -368,7 +320,7 @@ export function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {})

if (result.result === Result.SUCCESS) {
queueMicrotask(() => {
const ext = session.getExtension(details.id)
const ext = webStoreState.session.getExtension(details.id)
if (ext) {
// TODO: use WebFrameMain.isDestroyed
try {
Expand Down Expand Up @@ -459,7 +411,7 @@ export function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {})
})

handle('chrome.management.getAll', async (event) => {
const extensions = session.getAllExtensions()
const extensions = webStoreState.session.getAllExtensions()
return extensions.map(getExtensionInfo)
})

Expand Down Expand Up @@ -489,6 +441,82 @@ export function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {})
)
}

interface ElectronChromeWebStoreOptions {
/**
* Session to enable the Chrome Web Store in.
* Defaults to session.defaultSession
*/
session?: Electron.Session

/**
* Path to the 'electron-chrome-web-store' module.
*/
modulePath?: string

/**
* Path to extensions directory.
* Defaults to 'Extensions/' under app's userData path.
*/
extensionsPath?: string

/**
* Load extensions installed by Chrome Web Store.
* Defaults to true.
*/
loadExtensions?: boolean

/**
* Whether to allow loading unpacked extensions. Only loads if
* `loadExtensions` is also enabled.
* Defaults to false.
*/
allowUnpackedExtensions?: boolean

/**
* List of allowed extension IDs to install.
*/
allowlist?: ExtensionId[]

/**
* List of denied extension IDs to install.
*/
denylist?: ExtensionId[]
}

/**
* Install Chrome Web Store support.
*
* @param options Chrome Web Store configuration options.
*/
export function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) {
const session = opts.session || electronSession.defaultSession
const extensionsPath = opts.extensionsPath || path.join(app.getPath('userData'), 'Extensions')
const modulePath = opts.modulePath || __dirname
const loadExtensions = typeof opts.loadExtensions === 'boolean' ? opts.loadExtensions : true
const allowUnpackedExtensions =
typeof opts.allowUnpackedExtensions === 'boolean' ? opts.allowUnpackedExtensions : false

const webStoreState: WebStoreState = {
session,
extensionsPath,
installing: new Set(),
allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined,
denylist: opts.denylist ? new Set(opts.denylist) : undefined,
}

// Add preload script to session
const preloadPath = path.join(modulePath, 'dist/renderer/web-store-preload.js')
session.setPreloads([...session.getPreloads(), preloadPath])

addIpcListeners(webStoreState)

app.whenReady().then(() => {
if (loadExtensions) {
loadAllExtensions(session, extensionsPath, allowUnpackedExtensions)
}
})
}

/**
* @deprecated Use `installChromeWebStore`
*/
Expand Down
121 changes: 121 additions & 0 deletions packages/electron-chrome-web-store/src/browser/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as fs from 'node:fs'
import * as path from 'node:path'

import { generateId } from './id'

const d = require('debug')('electron-chrome-web-store:loader')

type ExtensionPathInfo =
| { type: 'store'; manifest: chrome.runtime.Manifest; path: string; id: string }
| { type: 'unpacked'; manifest: chrome.runtime.Manifest; path: string }

const manifestExists = async (dirPath: string) => {
if (!dirPath) return false
const manifestPath = path.join(dirPath, 'manifest.json')
try {
return (await fs.promises.stat(manifestPath)).isFile()
} catch {
return false
}
}

/**
* Discover list of extensions in the given path.
*/
async function discoverExtensions(extensionsPath: string): Promise<ExtensionPathInfo[]> {
// Get top level directories
const subDirectories = await fs.promises.readdir(extensionsPath, {
withFileTypes: true,
})

// Find all directories containing extension manifest.json
// Limits search depth to 1-2.
const extensionDirectories = await Promise.all(
subDirectories
.filter((dirEnt) => dirEnt.isDirectory())
.map(async (dirEnt) => {
const extPath = path.join(extensionsPath, dirEnt.name)

// Check if manifest exists in root directory
if (await manifestExists(extPath)) {
return extPath
}

// Check one level deeper
const extSubDirs = await fs.promises.readdir(extPath, {
withFileTypes: true,
})

// Look for manifest in each subdirectory
for (const subDir of extSubDirs) {
if (!subDir.isDirectory()) continue

const subDirPath = path.join(extPath, subDir.name)
if (await manifestExists(subDirPath)) {
return subDirPath
}
}
})
)

const results: ExtensionPathInfo[] = []

for (const extPath of extensionDirectories.filter(Boolean)) {
console.log(`Loading extension from ${extPath}`)
try {
const manifestPath = path.join(extPath!, 'manifest.json')
const manifestJson = (await fs.promises.readFile(manifestPath)).toString()
const manifest: chrome.runtime.Manifest = JSON.parse(manifestJson)
if (manifest.key) {
results.push({
type: 'store',
path: extPath!,
manifest,
id: generateId(manifest.key),
})
} else {
results.push({
type: 'unpacked',
path: extPath!,
manifest,
})
}
} catch (e) {
console.error(e)
}
}

return results
}

/**
* Load all extensions from the given directory.
*/
export async function loadAllExtensions(
session: Electron.Session,
extensionsPath: string,
allowUnpacked: boolean
) {
const extensions = await discoverExtensions(extensionsPath)
d('discovered %d extension(s)', extensions.length)

for (const ext of extensions) {
try {
if (ext.type === 'store') {
const existingExt = session.getExtension(ext.id)
if (existingExt) {
d('skipping loading existing extension %s', ext.id)
continue
}
d('loading extension %s', ext.id)
await session.loadExtension(ext.path)
} else if (allowUnpacked) {
d('loading unpacked extension %s', ext.path)
await session.loadExtension(ext.path)
}
} catch (error) {
console.error(`Failed to load extension from ${ext.path}`)
console.error(error)
}
}
}
Loading

0 comments on commit 0a08342

Please sign in to comment.