feat: 新增包文件

This commit is contained in:
duanshuwen
2025-11-15 22:41:50 +08:00
parent 7b65193e5c
commit 7ada85f175
104 changed files with 11273 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
dist
*.preload.js

View File

@@ -0,0 +1,3 @@
src
tsconfig.json
esbuild.config.js

View File

@@ -0,0 +1,143 @@
# electron-chrome-web-store
Install and update Chrome extensions from the Chrome Web Store for Electron.
## Usage
```
npm install electron-chrome-web-store
```
> [!TIP]
> To enable full support for Chrome extensions in Electron, install [electron-chrome-extensions](https://www.npmjs.com/package/electron-chrome-extensions).
### Enable downloading extensions from the Chrome Web Store
```js
const { app, BrowserWindow, session } = require('electron')
const { installChromeWebStore } = require('electron-chrome-web-store')
app.whenReady().then(async () => {
const browserSession = session.defaultSession
const browserWindow = new BrowserWindow({
webPreferences: {
session: browserSession,
},
})
// Install Chrome web store and wait for extensions to load
await installChromeWebStore({ session: browserSession })
browserWindow.loadURL('https://chromewebstore.google.com/')
})
```
### Install and update extensions programmatically
```js
const { app, session } = require('electron')
const { installExtension, updateExtensions } = require('electron-chrome-web-store')
app.whenReady().then(async () => {
// Install Dark Reader
await installExtension('eimadpbcbfnmbkopoojfekhnkhdbieeh')
// Install React Developer Tools with file:// access
await installExtension('fmkadmapgofadopljbjfkapdkoienihi', {
loadExtensionOptions: { allowFileAccess: true },
})
// Install uBlock Origin Lite to custom session
await installExtension('ddkjiahejlhfcafbddmgiahcphecmpfh', {
session: session.fromPartition('persist:browser'),
})
// Check and install updates for all loaded extensions
await updateExtensions()
})
```
### Packaging the preload script
This module uses a [preload script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#what-is-a-preload-script).
When packaging your application, it's required that the preload script is included. This can be
handled in two ways:
1. Include `node_modules` in your packaged app. This allows `electron-chrome-web-store/preload` to
be resolved.
2. In the case of using JavaScript bundlers, you may need to copy the preload script next to your
app's entry point script. You can try using
[copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin),
[vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy),
or [rollup-plugin-copy](https://github.com/vladshcherbin/rollup-plugin-copy) depending on your app's
configuration.
Here's an example for webpack configurations:
```js
module.exports = {
entry: './index.js',
plugins: [
new CopyWebpackPlugin({
patterns: [require.resolve('electron-chrome-web-store/preload')],
}),
],
}
```
## API
### `installChromeWebStore`
Installs Chrome Web Store support in the specified session.
- `options`
- `session`: The Electron session to enable the Chrome Web Store in. Defaults to `session.defaultSession`.
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
- `autoUpdate`: Whether to auto-update web store extensions at startup and once every 5 hours. Defaults to true.
- `loadExtensions`: A boolean indicating whether to load extensions installed by Chrome Web Store. Defaults to true.
- `allowUnpackedExtensions`: A boolean indicating whether to allow loading unpacked extensions. Only loads if `loadExtensions` is also enabled. Defaults to false.
- `allowlist`: An array of allowed extension IDs to install.
- `denylist`: An array of denied extension IDs to install.
- `beforeInstall`: A function which receives install details and returns a promise. Allows for prompting prior to install.
### `installExtension`
Installs Chrome extension from the Chrome Web Store.
- `extensionId`: The Chrome Web Store extension ID to install.
- `options`
- `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
- `loadExtensionOptions`: Extension options passed into `session.extensions.loadExtension`.
### `uninstallExtension`
Uninstalls Chrome Web Store extension.
- `extensionId`: The Chrome Web Store extension ID to uninstall.
- `options`
- `session`: The Electron session where extensions are loaded. Defaults to `session.defaultSession`.
- `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
### `updateExtensions`
Checks loaded extensions for updates and installs any if available.
- `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
### `loadAllExtensions`
Loads all extensions from the specified directory.
- `session`: The Electron session to load extensions in.
- `extensionsPath`: The path to the directory containing the extensions.
- `options`: An object with the following property:
- `allowUnpacked`: A boolean indicating whether to allow loading unpacked extensions. Defaults to false.
> [!NOTE]
> The `installChromeWebStore` API will automatically load web store extensions by default.
## License
MIT

View File

@@ -0,0 +1,36 @@
const packageJson = require('./package.json')
const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base')
console.log(`building ${packageJson.name}`)
const external = [...EXTERNAL_BASE, 'adm-zip', 'pbf', 'electron-chrome-web-store/preload']
const esmOnlyModules = ['pbf']
const browserConfig = createConfig({
entryPoints: ['src/browser/index.ts'],
outfile: 'dist/cjs/browser/index.js',
platform: 'node',
external: external.filter((module) => !esmOnlyModules.includes(module)),
})
const browserESMConfig = createConfig({
entryPoints: ['src/browser/index.ts'],
outfile: 'dist/esm/browser/index.mjs',
platform: 'neutral',
external,
format: 'esm',
})
build(browserConfig)
build(browserESMConfig)
const preloadConfig = createConfig({
entryPoints: ['src/renderer/chrome-web-store.preload.ts'],
outfile: 'dist/chrome-web-store.preload.js',
platform: 'browser',
external,
sourcemap: false,
})
build(preloadConfig)

View File

@@ -0,0 +1,43 @@
{
"name": "electron-chrome-web-store",
"version": "1.0.0",
"description": "Install and update Chrome extensions from the Chrome Web Store for Electron",
"main": "./dist/cjs/browser/index.js",
"module": "./dist/esm/browser/index.mjs",
"types": "./dist/types/browser/index.d.ts",
"exports": {
".": {
"types": "./dist/types/browser/index.d.ts",
"import": "./dist/esm/browser/index.mjs",
"require": "./dist/cjs/browser/index.js"
},
"./preload": "./dist/chrome-web-store.preload.js"
},
"scripts": {
"build": "yarn clean && tsc && node esbuild.config.js",
"clean": "node ../../scripts/clean.js",
"prepublish": "NODE_ENV=production yarn build"
},
"keywords": [
"electron",
"chrome",
"web",
"store",
"webstore",
"extensions"
],
"repository": "",
"author": "Damon <damon.shuwen@gmail.com>",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.24.0",
"rimraf": "^6.0.1",
"typescript": "^4.5.4"
},
"dependencies": {
"@types/chrome": "^0.0.287",
"adm-zip": "^0.5.16",
"debug": "^4.3.7",
"pbf": "^4.0.1"
}
}

View File

@@ -0,0 +1,348 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import debug from 'debug'
import { app, BrowserWindow, ipcMain, nativeImage, NativeImage, Session } from 'electron'
import { fetch } from './utils'
import {
ExtensionInstallStatus,
MV2DeprecationStatus,
Result,
WebGlStatus,
} from '../common/constants'
import { installExtension, uninstallExtension } from './installer'
import { ExtensionId, WebStoreState } from './types'
const d = debug('electron-chrome-web-store:api')
const WEBSTORE_URL = 'https://chromewebstore.google.com'
function getExtensionInfo(ext: Electron.Extension) {
const manifest: chrome.runtime.Manifest = ext.manifest
return {
description: manifest.description || '',
enabled: !manifest.disabled,
homepageUrl: manifest.homepage_url || '',
hostPermissions: manifest.host_permissions || [],
icons: Object.entries(manifest?.icons || {}).map(([size, url]) => ({
size: parseInt(size),
url: `chrome://extension-icon/${ext.id}/${size}/0`,
})),
id: ext.id,
installType: 'normal',
isApp: !!manifest.app,
mayDisable: true,
name: manifest.name,
offlineEnabled: !!manifest.offline_enabled,
optionsUrl: manifest.options_page
? `chrome-extension://${ext.id}/${manifest.options_page}`
: '',
permissions: manifest.permissions || [],
shortName: manifest.short_name || manifest.name,
type: manifest.app ? 'app' : 'extension',
updateUrl: manifest.update_url || '',
version: manifest.version,
}
}
function getExtensionInstallStatus(
state: WebStoreState,
extensionId: ExtensionId,
manifest?: chrome.runtime.Manifest,
) {
if (manifest && manifest.manifest_version < state.minimumManifestVersion) {
return ExtensionInstallStatus.DEPRECATED_MANIFEST_VERSION
}
if (state.denylist?.has(extensionId)) {
return ExtensionInstallStatus.BLOCKED_BY_POLICY
}
if (state.allowlist && !state.allowlist.has(extensionId)) {
return ExtensionInstallStatus.BLOCKED_BY_POLICY
}
const sessionExtensions = state.session.extensions || state.session
const extensions = sessionExtensions.getAllExtensions()
const extension = extensions.find((ext) => ext.id === extensionId)
if (!extension) {
return ExtensionInstallStatus.INSTALLABLE
}
if (extension.manifest.disabled) {
return ExtensionInstallStatus.DISABLED
}
return ExtensionInstallStatus.ENABLED
}
interface InstallDetails {
id: string
manifest: string
localizedName: string
esbAllowlist: boolean
iconUrl: string
}
async function beginInstall(
{ sender, senderFrame }: Electron.IpcMainInvokeEvent,
state: WebStoreState,
details: InstallDetails,
) {
const extensionId = details.id
try {
if (state.installing.has(extensionId)) {
return { result: Result.INSTALL_IN_PROGRESS }
}
let manifest: chrome.runtime.Manifest
try {
manifest = JSON.parse(details.manifest)
} catch {
return { result: Result.MANIFEST_ERROR }
}
const installStatus = getExtensionInstallStatus(state, extensionId, manifest)
switch (installStatus) {
case ExtensionInstallStatus.INSTALLABLE:
break // good to go
case ExtensionInstallStatus.BLOCKED_BY_POLICY:
return { result: Result.BLOCKED_BY_POLICY }
default: {
d('unable to install extension %s with status "%s"', extensionId, installStatus)
return { result: Result.UNKNOWN_ERROR }
}
}
let iconUrl: URL
try {
iconUrl = new URL(details.iconUrl)
} catch {
return { result: Result.INVALID_ICON_URL }
}
let icon: NativeImage
try {
const response = await fetch(iconUrl.href)
const imageBuffer = Buffer.from(await response.arrayBuffer())
icon = nativeImage.createFromBuffer(imageBuffer)
} catch {
return { result: Result.ICON_ERROR }
}
const browserWindow = BrowserWindow.fromWebContents(sender)
if (!senderFrame || senderFrame.isDestroyed()) {
return { result: Result.UNKNOWN_ERROR }
}
if (state.beforeInstall) {
const result: unknown = await state.beforeInstall({
id: extensionId,
localizedName: details.localizedName,
manifest,
icon,
frame: senderFrame,
browserWindow: browserWindow || undefined,
})
if (typeof result !== 'object' || typeof (result as any).action !== 'string') {
return { result: Result.UNKNOWN_ERROR }
} else if ((result as any).action !== 'allow') {
return { result: Result.USER_CANCELLED }
}
}
state.installing.add(extensionId)
await installExtension(extensionId, state)
return { result: Result.SUCCESS }
} catch (error) {
console.error('Extension installation failed:', error)
return {
result: Result.INSTALL_ERROR,
message: error instanceof Error ? error.message : String(error),
}
} finally {
state.installing.delete(extensionId)
}
}
type IPCChannelHandler = (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any
const handledIpcChannels = new Map<string, Map<Session, IPCChannelHandler>>()
export function registerWebStoreApi(webStoreState: WebStoreState) {
/** Handle IPCs from the Chrome Web Store. */
const handle = (
channel: string,
handle: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
) => {
let handlersMap = handledIpcChannels.get(channel)
// Handle each channel only once
if (!handlersMap) {
handlersMap = new Map<Session, IPCChannelHandler>()
handledIpcChannels.set(channel, handlersMap)
ipcMain.handle(channel, async function handleWebStoreIpc(event, ...args) {
d('received %s', channel)
const senderOrigin = event.senderFrame?.origin
if (!senderOrigin || !senderOrigin.startsWith(WEBSTORE_URL)) {
d('ignoring webstore request from %s', senderOrigin)
return
}
const session = event.sender.session
const handler = handlersMap?.get(session)
if (!handler) {
d('no handler for session %s', session.storagePath)
return
}
const result = await handler(event, ...args)
d('%s result', channel, result)
return result
})
}
// Add handler
handlersMap.set(webStoreState.session, handle)
}
handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => {
const { senderFrame } = event
d('beginInstall', details)
const result = await beginInstall(event, webStoreState, details)
if (result.result === Result.SUCCESS) {
queueMicrotask(() => {
const sessionExtensions = webStoreState.session.extensions || webStoreState.session
const ext = sessionExtensions.getExtension(details.id)
if (ext && senderFrame && !senderFrame.isDestroyed()) {
try {
senderFrame.send('chrome.management.onInstalled', getExtensionInfo(ext))
} catch (error) {
console.error(error)
}
}
})
}
return result
})
handle('chromeWebstore.completeInstall', async (event, id) => {
// TODO: Implement completion of extension installation
return Result.SUCCESS
})
handle('chromeWebstore.enableAppLauncher', async (event, enable) => {
// TODO: Implement app launcher enable/disable
return true
})
handle('chromeWebstore.getBrowserLogin', async () => {
// TODO: Implement getting browser login
return ''
})
handle('chromeWebstore.getExtensionStatus', async (_event, id, manifestJson) => {
const manifest = JSON.parse(manifestJson)
return getExtensionInstallStatus(webStoreState, id, manifest)
})
handle('chromeWebstore.getFullChromeVersion', async () => {
return {
version_number: process.versions.chrome,
app_name: app.getName(),
}
})
handle('chromeWebstore.getIsLauncherEnabled', async () => {
// TODO: Implement checking if launcher is enabled
return true
})
handle('chromeWebstore.getMV2DeprecationStatus', async () => {
return webStoreState.minimumManifestVersion > 2
? MV2DeprecationStatus.SOFT_DISABLE
: MV2DeprecationStatus.INACTIVE
})
handle('chromeWebstore.getReferrerChain', async () => {
// TODO: Implement getting referrer chain
return 'EgIIAA=='
})
handle('chromeWebstore.getStoreLogin', async () => {
// TODO: Implement getting store login
return ''
})
handle('chromeWebstore.getWebGLStatus', async () => {
await app.getGPUInfo('basic')
const features = app.getGPUFeatureStatus()
return features.webgl.startsWith('enabled')
? WebGlStatus.WEBGL_ALLOWED
: WebGlStatus.WEBGL_BLOCKED
})
handle('chromeWebstore.install', async (event, id, silentInstall) => {
// TODO: Implement extension installation
return Result.SUCCESS
})
handle('chromeWebstore.isInIncognitoMode', async () => {
// TODO: Implement incognito mode check
return false
})
handle('chromeWebstore.isPendingCustodianApproval', async (event, id) => {
// TODO: Implement custodian approval check
return false
})
handle('chromeWebstore.setStoreLogin', async (event, login) => {
// TODO: Implement setting store login
return true
})
handle('chrome.runtime.getManifest', async () => {
// TODO: Implement getting extension manifest
return {}
})
handle('chrome.management.getAll', async (event) => {
const sessionExtensions = webStoreState.session.extensions || webStoreState.session
const extensions = sessionExtensions.getAllExtensions()
return extensions.map(getExtensionInfo)
})
handle('chrome.management.setEnabled', async (event, id, enabled) => {
// TODO: Implement enabling/disabling extension
return true
})
handle(
'chrome.management.uninstall',
async (event, id, options: { showConfirmDialog: boolean }) => {
if (options?.showConfirmDialog) {
// TODO: confirmation dialog
}
try {
await uninstallExtension(id, webStoreState)
queueMicrotask(() => {
event.sender.send('chrome.management.onUninstalled', id)
})
return Result.SUCCESS
} catch (error) {
console.error(error)
return Result.UNKNOWN_ERROR
}
},
)
}

View File

@@ -0,0 +1,62 @@
// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
syntax = "proto2";
option optimize_for = LITE_RUNTIME;
package crx_file;
// A CRX₃ file is a binary file of the following format:
// [4 octets]: "Cr24", a magic number.
// [4 octets]: The version of the *.crx file format used (currently 3).
// [4 octets]: N, little-endian, the length of the header section.
// [N octets]: The header (the binary encoding of a CrxFileHeader).
// [M octets]: The ZIP archive.
// Clients should reject CRX₃ files that contain an N that is too large for the
// client to safely handle in memory.
message CrxFileHeader {
// PSS signature with RSA public key. The public key is formatted as a
// X.509 SubjectPublicKeyInfo block, as in CRX₂. In the common case of a
// developer key proof, the first 128 bits of the SHA-256 hash of the
// public key must equal the crx_id.
repeated AsymmetricKeyProof sha256_with_rsa = 2;
// ECDSA signature, using the NIST P-256 curve. Public key appears in
// named-curve format.
// The pinned algorithm will be this, at least on 2017-01-01.
repeated AsymmetricKeyProof sha256_with_ecdsa = 3;
// A verified contents file containing signatures over the archive contents.
// The verified contents are encoded in UTF-8 and then GZIP-compressed.
// Consult
// https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/verified_contents.h
// for information about the verified contents format.
optional bytes verified_contents = 4;
// The binary form of a SignedData message. We do not use a nested
// SignedData message, as handlers of this message must verify the proofs
// on exactly these bytes, so it is convenient to parse in two steps.
//
// All proofs in this CrxFile message are on the value
// "CRX3 SignedData\x00" + signed_header_size + signed_header_data +
// archive, where "\x00" indicates an octet with value 0, "CRX3 SignedData"
// is encoded using UTF-8, signed_header_size is the size in octets of the
// contents of this field and is encoded using 4 octets in little-endian
// order, signed_header_data is exactly the content of this field, and
// archive is the remaining contents of the file following the header.
optional bytes signed_header_data = 10000;
}
message AsymmetricKeyProof {
optional bytes public_key = 1;
optional bytes signature = 2;
}
message SignedData {
// This is simple binary, not UTF-8 encoded mpdecimal; i.e. it is exactly
// 16 bytes long.
optional bytes crx_id = 1;
}

View File

@@ -0,0 +1,55 @@
// code generated by pbf v4.0.1
// modified for electron-chrome-web-store
import Pbf from 'pbf'
interface AsymmetricKeyProof {
public_key: Buffer
signature: Buffer
}
interface CrxFileHeader {
sha256_with_rsa: AsymmetricKeyProof[]
sha256_with_ecdsa: AsymmetricKeyProof[]
verified_contents?: Buffer
signed_header_data?: Buffer
}
export function readCrxFileHeader(pbf: Pbf, end?: any): CrxFileHeader {
return pbf.readFields(
readCrxFileHeaderField,
{
sha256_with_rsa: [],
sha256_with_ecdsa: [],
verified_contents: undefined,
signed_header_data: undefined,
},
end,
)
}
function readCrxFileHeaderField(tag: any, obj: any, pbf: Pbf) {
if (tag === 2) obj.sha256_with_rsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
else if (tag === 3)
obj.sha256_with_ecdsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
else if (tag === 4) obj.verified_contents = pbf.readBytes()
else if (tag === 10000) obj.signed_header_data = pbf.readBytes()
}
export function readAsymmetricKeyProof(pbf: Pbf, end: any) {
return pbf.readFields(
readAsymmetricKeyProofField,
{ public_key: undefined, signature: undefined },
end,
)
}
function readAsymmetricKeyProofField(tag: any, obj: any, pbf: Pbf) {
if (tag === 1) obj.public_key = pbf.readBytes()
else if (tag === 2) obj.signature = pbf.readBytes()
}
export function readSignedData(pbf: Pbf, end?: any): { crx_id?: Buffer } {
return pbf.readFields(readSignedDataField, { crx_id: undefined }, end)
}
function readSignedDataField(tag: any, obj: any, pbf: Pbf) {
if (tag === 1) obj.crx_id = pbf.readBytes()
}

View File

@@ -0,0 +1,2 @@
declare module 'adm-zip'
declare module 'debug'

View File

@@ -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.
*/
export 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)
}

View File

@@ -0,0 +1,162 @@
import { app, session as electronSession } from 'electron'
import * as path from 'node:path'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { registerWebStoreApi } from './api'
import { loadAllExtensions } from './loader'
export { loadAllExtensions } from './loader'
export { installExtension, uninstallExtension, downloadExtension } from './installer'
import { initUpdater } from './updater'
export { updateExtensions } from './updater'
import { getDefaultExtensionsPath } from './utils'
import { BeforeInstall, ExtensionId, WebStoreState } from './types'
function resolvePreloadPath(modulePath?: string) {
// Attempt to resolve preload path from module exports
try {
return createRequire(__dirname).resolve('electron-chrome-web-store/preload')
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error(error)
}
}
const preloadFilename = 'chrome-web-store.preload.js'
// Deprecated: use modulePath if provided
if (modulePath) {
process.emitWarning(
'electron-chrome-web-store: "modulePath" is deprecated and will be removed in future versions.',
{ type: 'DeprecationWarning' },
)
return path.join(modulePath, 'dist', preloadFilename)
}
// Fallback to preload relative to entrypoint directory
return path.join(__dirname, preloadFilename)
}
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.
*
* @deprecated See "Packaging the preload script" in the readme.
*/
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[]
/**
* Whether extensions should auto-update.
*/
autoUpdate?: boolean
/**
* Minimum supported version of Chrome extensions.
* Defaults to 3.
*/
minimumManifestVersion?: number
/**
* Called prior to installing an extension. If implemented, return a Promise
* which resolves with `{ action: 'allow' | 'deny' }` depending on the action
* to be taken.
*/
beforeInstall?: BeforeInstall
}
/**
* Install Chrome Web Store support.
*
* @param options Chrome Web Store configuration options.
*/
export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) {
const session = opts.session || electronSession.defaultSession
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
const loadExtensions = typeof opts.loadExtensions === 'boolean' ? opts.loadExtensions : true
const allowUnpackedExtensions =
typeof opts.allowUnpackedExtensions === 'boolean' ? opts.allowUnpackedExtensions : false
const autoUpdate = typeof opts.autoUpdate === 'boolean' ? opts.autoUpdate : true
const minimumManifestVersion =
typeof opts.minimumManifestVersion === 'number' ? opts.minimumManifestVersion : 3
const beforeInstall = typeof opts.beforeInstall === 'function' ? opts.beforeInstall : undefined
const webStoreState: WebStoreState = {
session,
extensionsPath,
installing: new Set(),
allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined,
denylist: opts.denylist ? new Set(opts.denylist) : undefined,
minimumManifestVersion,
beforeInstall,
}
// Add preload script to session
const preloadPath = resolvePreloadPath(opts.modulePath)
if ('registerPreloadScript' in session) {
session.registerPreloadScript({
id: 'electron-chrome-web-store',
type: 'frame',
filePath: preloadPath,
})
} else {
// @ts-expect-error Deprecated electron@<35
session.setPreloads([...session.getPreloads(), preloadPath])
}
if (!existsSync(preloadPath)) {
console.error(
new Error(
`electron-chrome-web-store: Preload file not found at "${preloadPath}". ` +
'See "Packaging the preload script" in the readme.',
),
)
}
registerWebStoreApi(webStoreState)
await app.whenReady()
if (loadExtensions) {
await loadAllExtensions(session, extensionsPath, { allowUnpacked: allowUnpackedExtensions })
}
if (autoUpdate) {
void initUpdater(webStoreState)
}
}

View File

@@ -0,0 +1,276 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { session as electronSession } from 'electron'
import AdmZip from 'adm-zip'
import debug from 'debug'
import Pbf from 'pbf'
import { readCrxFileHeader, readSignedData } from './crx3'
import { convertHexadecimalToIDAlphabet, generateId } from './id'
import { fetch, getChromeVersion, getDefaultExtensionsPath } from './utils'
import { findExtensionInstall } from './loader'
import { ExtensionId } from './types'
const d = debug('electron-chrome-web-store:installer')
function getExtensionCrxURL(extensionId: ExtensionId) {
const url = new URL('https://clients2.google.com/service/update2/crx')
url.searchParams.append('response', 'redirect')
url.searchParams.append('acceptformat', ['crx2', 'crx3'].join(','))
const x = new URLSearchParams()
x.append('id', extensionId)
x.append('uc', '')
url.searchParams.append('x', x.toString())
url.searchParams.append('prodversion', getChromeVersion())
return url.toString()
}
interface CrxInfo {
extensionId: string
version: number
header: Buffer
contents: Buffer
publicKey: Buffer
}
// Parse CRX header and extract contents
function parseCrx(buffer: Buffer): CrxInfo {
// CRX3 magic number: 'Cr24'
const magicNumber = buffer.toString('utf8', 0, 4)
if (magicNumber !== 'Cr24') {
throw new Error('Invalid CRX format')
}
// CRX3 format has version = 3 and header size at bytes 8-12
const version = buffer.readUInt32LE(4)
const headerSize = buffer.readUInt32LE(8)
// Extract header and contents
const header = buffer.subarray(12, 12 + headerSize)
const contents = buffer.subarray(12 + headerSize)
let extensionId: string
let publicKey: Buffer
// For CRX2 format
if (version === 2) {
const pubKeyLength = buffer.readUInt32LE(8)
const sigLength = buffer.readUInt32LE(12)
publicKey = buffer.subarray(16, 16 + pubKeyLength)
extensionId = generateId(publicKey.toString('base64'))
} else {
// For CRX3, extract public key from header
// CRX3 header contains a protocol buffer message
const crxFileHeader = readCrxFileHeader(new Pbf(header))
const crxSignedData = readSignedData(new Pbf(crxFileHeader.signed_header_data))
const declaredCrxId = crxSignedData.crx_id
? convertHexadecimalToIDAlphabet(crxSignedData.crx_id.toString('hex'))
: null
if (!declaredCrxId) {
throw new Error('Invalid CRX signed data')
}
// Need to find store key proof which matches the declared ID
const keyProof = crxFileHeader.sha256_with_rsa.find((proof) => {
const crxId = proof.public_key ? generateId(proof.public_key.toString('base64')) : null
return crxId === declaredCrxId
})
if (!keyProof) {
throw new Error('Invalid CRX key')
}
extensionId = declaredCrxId
publicKey = keyProof.public_key
}
return {
extensionId,
version,
header,
contents,
publicKey,
}
}
// Extract CRX contents and update manifest
async function unpackCrx(crx: CrxInfo, destPath: string): Promise<chrome.runtime.Manifest> {
// Create zip file from contents
const zip = new AdmZip(crx.contents)
// Extract zip to destination
zip.extractAllTo(destPath, true)
// Read manifest.json
const manifestPath = path.join(destPath, 'manifest.json')
const manifestContent = await fs.promises.readFile(manifestPath, 'utf8')
const manifest = JSON.parse(manifestContent) as chrome.runtime.Manifest
// Add public key to manifest
manifest.key = crx.publicKey.toString('base64')
// Write updated manifest back
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
return manifest
}
async function readCrx(crxPath: string) {
const crxBuffer = await fs.promises.readFile(crxPath)
return parseCrx(crxBuffer)
}
async function downloadCrx(url: string, dest: string) {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Failed to download extension')
}
const fileStream = fs.createWriteStream(dest)
const downloadStream = Readable.fromWeb(response.body as any)
await pipeline(downloadStream, fileStream)
}
export async function downloadExtensionFromURL(
url: string,
extensionsDir: string,
expectedExtensionId?: string,
): Promise<string> {
d('downloading %s', url)
const installUuid = crypto.randomUUID()
const crxPath = path.join(os.tmpdir(), `electron-cws-download_${installUuid}.crx`)
try {
await downloadCrx(url, crxPath)
const crx = await readCrx(crxPath)
if (expectedExtensionId && expectedExtensionId !== crx.extensionId) {
throw new Error(
`CRX mismatches expected extension ID: ${expectedExtensionId} !== ${crx.extensionId}`,
)
}
const unpackedPath = path.join(extensionsDir, crx.extensionId, installUuid)
await fs.promises.mkdir(unpackedPath, { recursive: true })
const manifest = await unpackCrx(crx, unpackedPath)
if (!manifest.version) {
throw new Error('Installed extension is missing manifest version')
}
const versionedPath = path.join(extensionsDir, crx.extensionId, `${manifest.version}_0`)
await fs.promises.rename(unpackedPath, versionedPath)
return versionedPath
} finally {
await fs.promises.rm(crxPath, { force: true })
}
}
/**
* Download and unpack extension to the given extensions directory.
*/
export async function downloadExtension(
extensionId: string,
extensionsDir: string,
): Promise<string> {
const url = getExtensionCrxURL(extensionId)
return await downloadExtensionFromURL(url, extensionsDir, extensionId)
}
interface CommonExtensionOptions {
/** Session to load extensions into. */
session?: Electron.Session
/**
* Directory where web store extensions will be installed.
* Defaults to `Extensions` under the app's `userData` directory.
*/
extensionsPath?: string
}
interface InstallExtensionOptions extends CommonExtensionOptions {
/** Options for loading the extension. */
loadExtensionOptions?: Electron.LoadExtensionOptions
}
interface UninstallExtensionOptions extends CommonExtensionOptions {}
/**
* Install extension from the web store.
*/
export async function installExtension(
extensionId: string,
opts: InstallExtensionOptions = {},
): Promise<Electron.Extension> {
d('installing %s', extensionId)
const session = opts.session || electronSession.defaultSession
const sessionExtensions = session.extensions || session
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
// Check if already loaded
const existingExtension = sessionExtensions.getExtension(extensionId)
if (existingExtension) {
d('%s already loaded', extensionId)
return existingExtension
}
// Check if already installed
const existingExtensionInfo = await findExtensionInstall(extensionId, extensionsPath)
if (existingExtensionInfo && existingExtensionInfo.type === 'store') {
d('%s already installed', extensionId)
return await sessionExtensions.loadExtension(
existingExtensionInfo.path,
opts.loadExtensionOptions,
)
}
// Download and load new extension
const extensionPath = await downloadExtension(extensionId, extensionsPath)
const extension = await sessionExtensions.loadExtension(extensionPath, opts.loadExtensionOptions)
d('installed %s', extensionId)
return extension
}
/**
* Uninstall extension from the web store.
*/
export async function uninstallExtension(
extensionId: string,
opts: UninstallExtensionOptions = {},
) {
d('uninstalling %s', extensionId)
const session = opts.session || electronSession.defaultSession
const sessionExtensions = session.extensions || session
const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
const extensions = sessionExtensions.getAllExtensions()
const existingExt = extensions.find((ext) => ext.id === extensionId)
if (existingExt) {
sessionExtensions.removeExtension(extensionId)
}
const extensionDir = path.join(extensionsPath, extensionId)
try {
const stat = await fs.promises.stat(extensionDir)
if (stat.isDirectory()) {
await fs.promises.rm(extensionDir, { recursive: true, force: true })
}
} catch (error: any) {
if (error?.code !== 'ENOENT') {
throw error
}
}
}

View File

@@ -0,0 +1,172 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import debug from 'debug'
import { generateId } from './id'
import { compareVersions } from './utils'
import { ExtensionId } from './types'
const d = debug('electron-chrome-web-store:loader')
type ExtensionPathBaseInfo = { manifest: chrome.runtime.Manifest; path: string }
type ExtensionPathInfo =
| ({ type: 'store'; id: string } & ExtensionPathBaseInfo)
| ({ type: 'unpacked' } & ExtensionPathBaseInfo)
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
}
}
/**
* DFS directories for extension manifests.
*/
async function extensionSearch(dirPath: string, depth: number = 0): Promise<string[]> {
if (depth >= 2) return []
const results = []
const dirEntries = await fs.promises.readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
if (entry.isDirectory()) {
if (await manifestExists(path.join(dirPath, entry.name))) {
results.push(path.join(dirPath, entry.name))
} else {
results.push(...(await extensionSearch(path.join(dirPath, entry.name), depth + 1)))
}
}
}
return results
}
/**
* Discover list of extensions in the given path.
*/
async function discoverExtensions(extensionsPath: string): Promise<ExtensionPathInfo[]> {
try {
const stat = await fs.promises.stat(extensionsPath)
if (!stat.isDirectory()) {
d('%s is not a directory', extensionsPath)
return []
}
} catch {
d('%s does not exist', extensionsPath)
return []
}
const extensionDirectories = await extensionSearch(extensionsPath)
const results: ExtensionPathInfo[] = []
for (const extPath of extensionDirectories.filter(Boolean)) {
try {
const manifestPath = path.join(extPath!, 'manifest.json')
const manifestJson = (await fs.promises.readFile(manifestPath)).toString()
const manifest: chrome.runtime.Manifest = JSON.parse(manifestJson)
const result = manifest.key
? {
type: 'store' as const,
path: extPath!,
manifest,
id: generateId(manifest.key),
}
: {
type: 'unpacked' as const,
path: extPath!,
manifest,
}
results.push(result)
} catch (e) {
console.error(e)
}
}
return results
}
/**
* Filter any outdated extensions in the case of duplicate installations.
*/
function filterOutdatedExtensions(extensions: ExtensionPathInfo[]): ExtensionPathInfo[] {
const uniqueExtensions: ExtensionPathInfo[] = []
const storeExtMap = new Map<ExtensionId, ExtensionPathInfo>()
for (const ext of extensions) {
if (ext.type === 'unpacked') {
// Unpacked extensions are always unique to their path
uniqueExtensions.push(ext)
} else if (!storeExtMap.has(ext.id)) {
// New store extension
storeExtMap.set(ext.id, ext)
} else {
// Existing store extension, compare with existing version
const latestExt = storeExtMap.get(ext.id)!
if (compareVersions(latestExt.manifest.version, ext.manifest.version) < 0) {
storeExtMap.set(ext.id, ext)
}
}
}
// Append up to date store extensions
storeExtMap.forEach((ext) => uniqueExtensions.push(ext))
return uniqueExtensions
}
/**
* Load all extensions from the given directory.
*/
export async function loadAllExtensions(
session: Electron.Session,
extensionsPath: string,
options: {
allowUnpacked?: boolean
} = {},
) {
const sessionExtensions = session.extensions || session
let extensions = await discoverExtensions(extensionsPath)
extensions = filterOutdatedExtensions(extensions)
d('discovered %d extension(s) in %s', extensions.length, extensionsPath)
for (const ext of extensions) {
try {
let extension: Electron.Extension | undefined
if (ext.type === 'store') {
const existingExt = sessionExtensions.getExtension(ext.id)
if (existingExt) {
d('skipping loading existing extension %s', ext.id)
continue
}
d('loading extension %s', `${ext.id}@${ext.manifest.version}`)
extension = await sessionExtensions.loadExtension(ext.path)
} else if (options.allowUnpacked) {
d('loading unpacked extension %s', ext.path)
extension = await sessionExtensions.loadExtension(ext.path)
}
if (
extension &&
extension.manifest.manifest_version === 3 &&
extension.manifest.background?.service_worker
) {
const scope = `chrome-extension://${extension.id}`
await session.serviceWorkers.startWorkerForScope(scope).catch(() => {
console.error(`Failed to start worker for extension ${extension.id}`)
})
}
} catch (error) {
console.error(`Failed to load extension from ${ext.path}`)
console.error(error)
}
}
}
export async function findExtensionInstall(extensionId: string, extensionsPath: string) {
const extensionPath = path.join(extensionsPath, extensionId)
let extensions = await discoverExtensions(extensionPath)
extensions = filterOutdatedExtensions(extensions)
return extensions.length > 0 ? extensions[0] : null
}

View File

@@ -0,0 +1,24 @@
export type ExtensionId = Electron.Extension['id']
export interface ExtensionInstallDetails {
id: string
localizedName: string
manifest: chrome.runtime.Manifest
icon: Electron.NativeImage
browserWindow?: Electron.BrowserWindow
frame: Electron.WebFrameMain
}
export type BeforeInstall = (
details: ExtensionInstallDetails,
) => Promise<{ action: 'allow' | 'deny' }>
export interface WebStoreState {
session: Electron.Session
extensionsPath: string
installing: Set<ExtensionId>
allowlist?: Set<ExtensionId>
denylist?: Set<ExtensionId>
minimumManifestVersion: number
beforeInstall?: BeforeInstall
}

View File

@@ -0,0 +1,327 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import debug from 'debug'
import { app, powerMonitor, session as electronSession } from 'electron'
import { compareVersions, fetch, getChromeVersion } from './utils'
import { downloadExtensionFromURL } from './installer'
import { WebStoreState } from './types'
const d = debug('electron-chrome-web-store:updater')
interface OmahaResponseBody {
response: {
server: string
protocol: string
daystart: {
elapsed_seconds: number
elapsed_days: number
}
app: Array<{
appid: string
cohort: string
status: string
cohortname: string
updatecheck: {
_esbAllowlist: string
status:
| 'ok'
| 'noupdate'
| 'error-internal'
| 'error-hash'
| 'error-osnotsupported'
| 'error-hwnotsupported'
| 'error-unsupportedprotocol'
urls?: {
url: Array<{
codebase: string
}>
}
manifest?: {
version: string
packages: {
package: Array<{
hash_sha256: string
size: number
name: string
fp: string
required: boolean
}>
}
}
}
}>
}
}
type ExtensionUpdate = {
extension: Electron.Extension
id: string
name: string
version: string
url: string
}
const SYSTEM_IDLE_DURATION = 1 * 60 * 60 * 1000 // 1 hour
const UPDATE_CHECK_INTERVAL = 5 * 60 * 60 * 1000 // 5 hours
const MIN_UPDATE_INTERVAL = 3 * 60 * 60 * 1000 // 3 hours
/** Time of last update check */
let lastUpdateCheck: number | undefined
/**
* Updates are limited to certain URLs for the initial implementation.
*/
const ALLOWED_UPDATE_URLS = new Set(['https://clients2.google.com/service/update2/crx'])
const getSessionId = (() => {
let sessionId: string
return () => sessionId || (sessionId = crypto.randomUUID())
})()
const getOmahaPlatform = (): string => {
switch (process.platform) {
case 'win32':
return 'win'
case 'darwin':
return 'mac'
default:
return process.platform
}
}
const getOmahaArch = (): string => {
switch (process.arch) {
case 'ia32':
return 'x86'
case 'x64':
return 'x64'
default:
return process.arch
}
}
function filterWebStoreExtension(extension: Electron.Extension) {
const manifest = extension.manifest as chrome.runtime.Manifest
if (!manifest) return false
// TODO: implement extension.isFromStore() to check creation flags
return manifest.key && manifest.update_url && ALLOWED_UPDATE_URLS.has(manifest.update_url)
}
async function fetchAvailableUpdates(extensions: Electron.Extension[]): Promise<ExtensionUpdate[]> {
if (extensions.length === 0) return []
const extensionIds = extensions.map((extension) => extension.id)
const extensionMap: Record<string, Electron.Extension> = extensions.reduce(
(map, ext) => ({
...map,
[ext.id]: ext,
}),
{},
)
const chromeVersion = getChromeVersion()
const url = 'https://update.googleapis.com/service/update2/json'
// Chrome's extension updater uses its Omaha Protocol.
// https://chromium.googlesource.com/chromium/src/+/main/docs/updater/protocol_3_1.md
const body = {
request: {
'@updater': 'electron-chrome-web-store',
acceptformat: 'crx3',
app: [
...extensions.map((extension) => ({
appid: extension.id,
updatecheck: {},
// API always reports 'noupdate' when version is set :thinking:
// version: extension.version,
})),
],
os: {
platform: getOmahaPlatform(),
arch: getOmahaArch(),
},
prodversion: chromeVersion,
protocol: '3.1',
requestid: crypto.randomUUID(),
sessionid: getSessionId(),
testsource: process.env.NODE_ENV === 'production' ? '' : 'electron_dev',
},
}
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'X-Goog-Update-Interactivity': 'bg',
'X-Goog-Update-AppId': extensionIds.join(','),
'X-Goog-Update-Updater': `chromiumcrx-${chromeVersion}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
d('update response not ok')
return []
}
// Skip safe JSON prefix
const text = await response.text()
const prefix = `)]}'\n`
if (!text.startsWith(prefix)) {
d('unexpected update response: %s', text)
return []
}
const json = text.substring(prefix.length)
const result: OmahaResponseBody = JSON.parse(json)
let updates: ExtensionUpdate[]
try {
const apps = result?.response?.app || []
updates = apps
// Find extensions with update
.filter((app) => app.updatecheck.status === 'ok')
// Collect info
.map((app) => {
const extensionId = app.appid
const extension = extensionMap[extensionId]
const manifest = app.updatecheck.manifest!
const pkg = manifest!.packages.package[0]
return {
extension,
id: extensionId,
version: manifest.version,
name: pkg.name,
url: app.updatecheck.urls!.url[0].codebase,
}
})
// Remove extensions without newer version
.filter((update) => {
const extension = extensionMap[update.id]
return compareVersions(extension.version, update.version) < 0
})
} catch (error) {
console.error('Unable to read extension updates response', error)
return []
}
return updates
}
async function updateExtension(session: Electron.Session, update: ExtensionUpdate) {
const sessionExtensions = session.extensions || session
const extensionId = update.id
const oldExtension = update.extension
d('updating %s %s -> %s', extensionId, oldExtension.version, update.version)
// Updates must be installed in adjacent directories. Ensure the old install
// was contained in a versioned directory structure.
const oldVersionDirectoryName = path.basename(oldExtension.path)
if (!oldVersionDirectoryName.startsWith(oldExtension.version)) {
console.error(
`updateExtension: extension ${extensionId} must conform to versioned directory names`,
{
oldPath: oldExtension.path,
},
)
d('skipping %s update due to invalid install path %s', extensionId, oldExtension.path)
return
}
// Download update
const extensionsPath = path.join(oldExtension.path, '..', '..')
const updatePath = await downloadExtensionFromURL(update.url, extensionsPath, extensionId)
d('downloaded update %s@%s', extensionId, update.version)
// Reload extension if already loaded
if (sessionExtensions.getExtension(extensionId)) {
sessionExtensions.removeExtension(extensionId)
await sessionExtensions.loadExtension(updatePath)
d('loaded update %s@%s', extensionId, update.version)
}
// Remove old version
await fs.promises.rm(oldExtension.path, { recursive: true, force: true })
}
async function checkForUpdates(session: Electron.Session) {
// Only check for extensions from the store
const sessionExtensions = session.extensions || session
const extensions = sessionExtensions.getAllExtensions().filter(filterWebStoreExtension)
d('checking for updates: %s', extensions.map((ext) => `${ext.id}@${ext.version}`).join(','))
const updates = await fetchAvailableUpdates(extensions)
if (!updates || updates.length === 0) {
d('no updates found')
return []
}
return updates
}
async function installUpdates(session: Electron.Session, updates: ExtensionUpdate[]) {
d('updating %d extension(s)', updates.length)
for (const update of updates) {
try {
await updateExtension(session, update)
} catch (error) {
console.error(`checkForUpdates: Error updating extension ${update.id}`)
console.error(error)
}
}
}
/**
* Check session's loaded extensions for updates and install any if available.
*/
export async function updateExtensions(
session: Electron.Session = electronSession.defaultSession,
): Promise<void> {
const updates = await checkForUpdates(session)
if (updates.length > 0) {
await installUpdates(session, updates)
}
}
async function maybeCheckForUpdates(session: Electron.Session) {
const idleState = powerMonitor.getSystemIdleState(SYSTEM_IDLE_DURATION)
if (idleState !== 'active') {
d('skipping update check while system is in "%s" idle state', idleState)
return
}
// Determine if enough time has passed to check updates
if (lastUpdateCheck && Date.now() - lastUpdateCheck < MIN_UPDATE_INTERVAL) {
return
}
lastUpdateCheck = Date.now()
void updateExtensions(session)
}
export async function initUpdater(state: WebStoreState) {
const check = () => maybeCheckForUpdates(state.session)
switch (process.platform) {
case 'darwin':
app.on('did-become-active', check)
break
case 'win32':
case 'linux':
app.on('browser-window-focus', check)
break
}
const updateIntervalId = setInterval(check, UPDATE_CHECK_INTERVAL)
check()
app.on('before-quit', (event) => {
queueMicrotask(() => {
if (!event.defaultPrevented) {
d('stopping update checks')
clearInterval(updateIntervalId)
}
})
})
}

View File

@@ -0,0 +1,28 @@
import * as path from 'node:path'
import { app, net } from 'electron'
// Include fallbacks for node environments that aren't Electron
export const fetch =
// Prefer Node's fetch until net.fetch crash is fixed
// https://github.com/electron/electron/pull/45050
globalThis.fetch ||
net?.fetch ||
(() => {
throw new Error(
'electron-chrome-web-store: Missing fetch API. Please upgrade Electron or Node.',
)
})
export const getChromeVersion = () => process.versions.chrome || '131.0.6778.109'
export function compareVersions(version1: string, version2: string) {
const v1 = version1.split('.').map(Number)
const v2 = version2.split('.').map(Number)
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1
if (v1[i] < v2[i]) return -1
}
return 0
}
export const getDefaultExtensionsPath = () => path.join(app.getPath('userData'), 'Extensions')

View File

@@ -0,0 +1,47 @@
export const ExtensionInstallStatus = {
BLACKLISTED: 'blacklisted',
BLOCKED_BY_POLICY: 'blocked_by_policy',
CAN_REQUEST: 'can_request',
CORRUPTED: 'corrupted',
CUSTODIAN_APPROVAL_REQUIRED: 'custodian_approval_required',
CUSTODIAN_APPROVAL_REQUIRED_FOR_INSTALLATION: 'custodian_approval_required_for_installation',
DEPRECATED_MANIFEST_VERSION: 'deprecated_manifest_version',
DISABLED: 'disabled',
ENABLED: 'enabled',
FORCE_INSTALLED: 'force_installed',
INSTALLABLE: 'installable',
REQUEST_PENDING: 'request_pending',
TERMINATED: 'terminated',
}
export const MV2DeprecationStatus = {
INACTIVE: 'inactive',
SOFT_DISABLE: 'soft_disable',
WARNING: 'warning',
}
export const Result = {
ALREADY_INSTALLED: 'already_installed',
BLACKLISTED: 'blacklisted',
BLOCKED_BY_POLICY: 'blocked_by_policy',
BLOCKED_FOR_CHILD_ACCOUNT: 'blocked_for_child_account',
FEATURE_DISABLED: 'feature_disabled',
ICON_ERROR: 'icon_error',
INSTALL_ERROR: 'install_error',
INSTALL_IN_PROGRESS: 'install_in_progress',
INVALID_ICON_URL: 'invalid_icon_url',
INVALID_ID: 'invalid_id',
LAUNCH_IN_PROGRESS: 'launch_in_progress',
MANIFEST_ERROR: 'manifest_error',
MISSING_DEPENDENCIES: 'missing_dependencies',
SUCCESS: 'success',
UNKNOWN_ERROR: 'unknown_error',
UNSUPPORTED_EXTENSION_TYPE: 'unsupported_extension_type',
USER_CANCELLED: 'user_cancelled',
USER_GESTURE_REQUIRED: 'user_gesture_required',
}
export const WebGlStatus = {
WEBGL_ALLOWED: 'webgl_allowed',
WEBGL_BLOCKED: 'webgl_blocked',
}

View File

@@ -0,0 +1,326 @@
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import {
ExtensionInstallStatus,
MV2DeprecationStatus,
Result,
WebGlStatus,
} from '../common/constants'
interface WebstorePrivate {
ExtensionInstallStatus: typeof ExtensionInstallStatus
MV2DeprecationStatus: typeof MV2DeprecationStatus
Result: typeof Result
WebGlStatus: typeof WebGlStatus
beginInstallWithManifest3: (
details: unknown,
callback?: (result: string) => void,
) => Promise<string>
completeInstall: (id: string, callback?: (result: string) => void) => Promise<string>
enableAppLauncher: (enable: boolean, callback?: (result: boolean) => void) => Promise<boolean>
getBrowserLogin: (callback?: (result: string) => void) => Promise<string>
getExtensionStatus: (
id: string,
manifestJson: string,
callback?: (status: string) => void,
) => Promise<string>
getFullChromeVersion: (callback?: (result: string) => void) => Promise<{
version_number: string
app_name: string
}>
getIsLauncherEnabled: (callback?: (result: boolean) => void) => Promise<boolean>
getMV2DeprecationStatus: (callback?: (result: string) => void) => Promise<string>
getReferrerChain: (callback?: (result: unknown[]) => void) => Promise<unknown[]>
getStoreLogin: (callback?: (result: string) => void) => Promise<string>
getWebGLStatus: (callback?: (result: string) => void) => Promise<string>
install: (
id: string,
silentInstall: boolean,
callback?: (result: string) => void,
) => Promise<string>
isInIncognitoMode: (callback?: (result: boolean) => void) => Promise<boolean>
isPendingCustodianApproval: (id: string, callback?: (result: boolean) => void) => Promise<boolean>
setStoreLogin: (login: string, callback?: (result: boolean) => void) => Promise<boolean>
}
function updateBranding(appName: string) {
const update = () => {
requestAnimationFrame(() => {
const chromeButtons = Array.from(document.querySelectorAll('span')).filter((node) =>
node.innerText.includes('Chrome'),
)
for (const button of chromeButtons) {
button.innerText = button.innerText.replace('Chrome', appName)
}
})
}
// Try twice to ensure branding changes
update()
setTimeout(update, 1000 / 60)
}
function getUAProductVersion(userAgent: string, product: string) {
const regex = new RegExp(`${product}/([\\d.]+)`)
return userAgent.match(regex)?.[1]
}
function overrideUserAgent() {
const chromeVersion = getUAProductVersion(navigator.userAgent, 'Chrome') || '133.0.6920.0'
const userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
webFrame.executeJavaScript(
`(${function (userAgent: string) {
Object.defineProperty(navigator, 'userAgent', { value: userAgent })
}})(${JSON.stringify(userAgent)});`,
)
}
const DEBUG = process.env.NODE_ENV === 'development'
function log(...args: any[]) {
if (!DEBUG) return
console.debug(...args)
}
function setupChromeWebStoreApi() {
let appName: string | undefined
const setAppName = (name: string) => {
appName = name
updateBranding(appName)
}
const maybeUpdateBranding = () => {
if (appName) updateBranding(appName)
}
const setExtensionError = (message?: string) => {
webFrame.executeJavaScript(`
if (typeof chrome !== 'undefined') {
if (!chrome.extension) chrome.extension = {};
chrome.extension.lastError = ${JSON.stringify(message ? { message } : null)};
}
`)
}
/**
* Implementation of Chrome's webstorePrivate for Electron.
*/
const electronWebstore: WebstorePrivate = {
ExtensionInstallStatus,
MV2DeprecationStatus,
Result,
WebGlStatus,
beginInstallWithManifest3: async (details, callback) => {
log('webstorePrivate.beginInstallWithManifest3', details)
const { result, message } = await ipcRenderer.invoke('chromeWebstore.beginInstall', details)
log('webstorePrivate.beginInstallWithManifest3 result:', result)
setExtensionError(result === Result.SUCCESS ? null : message)
if (callback) callback(result)
return result
},
completeInstall: async (id, callback) => {
log('webstorePrivate.completeInstall', id)
const result = await ipcRenderer.invoke('chromeWebstore.completeInstall', id)
log('webstorePrivate.completeInstall result:', result)
if (callback) callback(result)
maybeUpdateBranding()
return result
},
enableAppLauncher: async (enable, callback) => {
log('webstorePrivate.enableAppLauncher', enable)
const result = await ipcRenderer.invoke('chromeWebstore.enableAppLauncher', enable)
log('webstorePrivate.enableAppLauncher result:', result)
if (callback) callback(result)
return result
},
getBrowserLogin: async (callback) => {
log('webstorePrivate.getBrowserLogin called')
const result = await ipcRenderer.invoke('chromeWebstore.getBrowserLogin')
log('webstorePrivate.getBrowserLogin result:', result)
if (callback) callback(result)
return result
},
getExtensionStatus: async (id, manifestJson, callback) => {
log('webstorePrivate.getExtensionStatus', id, { id, manifestJson, callback })
const result = await ipcRenderer.invoke('chromeWebstore.getExtensionStatus', id, manifestJson)
log('webstorePrivate.getExtensionStatus result:', id, result)
if (callback) callback(result)
maybeUpdateBranding()
return result
},
getFullChromeVersion: async (callback) => {
log('webstorePrivate.getFullChromeVersion called')
const result = await ipcRenderer.invoke('chromeWebstore.getFullChromeVersion')
log('webstorePrivate.getFullChromeVersion result:', result)
if (result.app_name) {
setAppName(result.app_name)
delete result.app_name
}
if (callback) callback(result)
return result
},
getIsLauncherEnabled: async (callback) => {
log('webstorePrivate.getIsLauncherEnabled called')
const result = await ipcRenderer.invoke('chromeWebstore.getIsLauncherEnabled')
log('webstorePrivate.getIsLauncherEnabled result:', result)
if (callback) callback(result)
return result
},
getMV2DeprecationStatus: async (callback) => {
log('webstorePrivate.getMV2DeprecationStatus called')
const result = await ipcRenderer.invoke('chromeWebstore.getMV2DeprecationStatus')
log('webstorePrivate.getMV2DeprecationStatus result:', result)
if (callback) callback(result)
return result
},
getReferrerChain: async (callback) => {
log('webstorePrivate.getReferrerChain called')
const result = await ipcRenderer.invoke('chromeWebstore.getReferrerChain')
log('webstorePrivate.getReferrerChain result:', result)
if (callback) callback(result)
return result
},
getStoreLogin: async (callback) => {
log('webstorePrivate.getStoreLogin called')
const result = await ipcRenderer.invoke('chromeWebstore.getStoreLogin')
log('webstorePrivate.getStoreLogin result:', result)
if (callback) callback(result)
return result
},
getWebGLStatus: async (callback) => {
log('webstorePrivate.getWebGLStatus called')
const result = await ipcRenderer.invoke('chromeWebstore.getWebGLStatus')
log('webstorePrivate.getWebGLStatus result:', result)
if (callback) callback(result)
return result
},
install: async (id, silentInstall, callback) => {
log('webstorePrivate.install', { id, silentInstall })
const result = await ipcRenderer.invoke('chromeWebstore.install', id, silentInstall)
log('webstorePrivate.install result:', result)
if (callback) callback(result)
return result
},
isInIncognitoMode: async (callback) => {
log('webstorePrivate.isInIncognitoMode called')
const result = await ipcRenderer.invoke('chromeWebstore.isInIncognitoMode')
log('webstorePrivate.isInIncognitoMode result:', result)
if (callback) callback(result)
return result
},
isPendingCustodianApproval: async (id, callback) => {
log('webstorePrivate.isPendingCustodianApproval', id)
const result = await ipcRenderer.invoke('chromeWebstore.isPendingCustodianApproval', id)
log('webstorePrivate.isPendingCustodianApproval result:', result)
if (callback) callback(result)
return result
},
setStoreLogin: async (login, callback) => {
log('webstorePrivate.setStoreLogin', login)
const result = await ipcRenderer.invoke('chromeWebstore.setStoreLogin', login)
log('webstorePrivate.setStoreLogin result:', result)
if (callback) callback(result)
return result
},
}
// Expose webstorePrivate API
contextBridge.exposeInMainWorld('electronWebstore', electronWebstore)
// Expose chrome.runtime and chrome.management APIs
const runtime = {
lastError: null,
getManifest: async () => {
log('chrome.runtime.getManifest called')
return {}
},
}
contextBridge.exposeInMainWorld('electronRuntime', runtime)
const management = {
onInstalled: {
addListener: (callback: () => void) => {
log('chrome.management.onInstalled.addListener called')
ipcRenderer.on('chrome.management.onInstalled', callback)
},
removeListener: (callback: () => void) => {
log('chrome.management.onInstalled.removeListener called')
ipcRenderer.removeListener('chrome.management.onInstalled', callback)
},
},
onUninstalled: {
addListener: (callback: () => void) => {
log('chrome.management.onUninstalled.addListener called')
ipcRenderer.on('chrome.management.onUninstalled', callback)
},
removeListener: (callback: () => void) => {
log('chrome.management.onUninstalled.removeListener called')
ipcRenderer.removeListener('chrome.management.onUninstalled', callback)
},
},
getAll: (callback: (extensions: any[]) => void) => {
log('chrome.management.getAll called')
ipcRenderer.invoke('chrome.management.getAll').then((result) => {
log('chrome.management.getAll result:', result)
callback(result)
})
},
setEnabled: async (id: string, enabled: boolean) => {
log('chrome.management.setEnabled', { id, enabled })
const result = await ipcRenderer.invoke('chrome.management.setEnabled', id, enabled)
log('chrome.management.setEnabled result:', result)
return result
},
uninstall: (id: string, options: { showConfirmDialog: boolean }, callback?: () => void) => {
log('chrome.management.uninstall', { id, options })
ipcRenderer.invoke('chrome.management.uninstall', id, options).then((result) => {
log('chrome.management.uninstall result:', result)
if (callback) callback()
})
},
}
contextBridge.exposeInMainWorld('electronManagement', management)
webFrame.executeJavaScript(`
(function () {
chrome.webstorePrivate = globalThis.electronWebstore;
Object.assign(chrome.runtime, electronRuntime);
Object.assign(chrome.management, electronManagement);
void 0;
}());
`)
// Fetch app name
electronWebstore.getFullChromeVersion()
// Replace branding
overrideUserAgent()
process.once('document-start', maybeUpdateBranding)
if ('navigation' in window) {
;(window.navigation as any).addEventListener('navigate', maybeUpdateBranding)
}
}
if (location.href.startsWith('https://chromewebstore.google.com')) {
log('Injecting Chrome Web Store API')
setupChromeWebStoreApi()
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"moduleResolution": "node",
"outDir": "dist/types",
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"],
"exclude": ["node_modules"]
}